diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b6d3dba --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..494c47a --- /dev/null +++ b/.gitignore @@ -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 diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..b9f7656 --- /dev/null +++ b/backend/.env.example @@ -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 diff --git a/backend/.runtime/vapid.env b/backend/.runtime/vapid.env new file mode 100644 index 0000000..2fcc02e --- /dev/null +++ b/backend/.runtime/vapid.env @@ -0,0 +1,3 @@ +VAPID_PUBLIC_KEY=BD96cEhyd-tuZnFVkfBeX1qd3SUY0u1gdzg0WL38R2VXtwULckSLJf6Zb6Xy_cbUfRlJtrBOMzMVCkksP63kH0s +VAPID_PRIVATE_KEY=i1ciBSrobPH6LBUDVWz3vmKHuhK-fJTORmvO1FnGGJ0 +VAPID_SUBJECT=mailto:admin@example.com diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..f2f9075 --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/config/bot-name-aliases.json b/backend/config/bot-name-aliases.json new file mode 100644 index 0000000..03199dc --- /dev/null +++ b/backend/config/bot-name-aliases.json @@ -0,0 +1,8 @@ +{ + "raketafon": "Ракетафон", + "pobeda-1-comand": "Победа", + "raketabas": "Ракетабас", + "sol-1www": "Сол 1WW", + "fon-stb": "Фон СТБ", + "fonat": "Фонат" +} \ No newline at end of file diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..c22422c --- /dev/null +++ b/backend/package.json @@ -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" + } +} diff --git a/backend/prisma/migrations/20260325140000_add_subscription_fields/migration.sql b/backend/prisma/migrations/20260325140000_add_subscription_fields/migration.sql new file mode 100644 index 0000000..4f8a269 --- /dev/null +++ b/backend/prisma/migrations/20260325140000_add_subscription_fields/migration.sql @@ -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; diff --git a/backend/prisma/migrations/20260325161000_add_password_reset_tokens/migration.sql b/backend/prisma/migrations/20260325161000_add_password_reset_tokens/migration.sql new file mode 100644 index 0000000..bb904cf --- /dev/null +++ b/backend/prisma/migrations/20260325161000_add_password_reset_tokens/migration.sql @@ -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; diff --git a/backend/prisma/migrations/20260326170000_add_user_session_version/migration.sql b/backend/prisma/migrations/20260326170000_add_user_session_version/migration.sql new file mode 100644 index 0000000..f533c9a --- /dev/null +++ b/backend/prisma/migrations/20260326170000_add_user_session_version/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "User" +ADD COLUMN "sessionVersion" INTEGER NOT NULL DEFAULT 0; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma new file mode 100644 index 0000000..14b2568 --- /dev/null +++ b/backend/prisma/schema.prisma @@ -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()) +} diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts new file mode 100644 index 0000000..b1ebfd3 --- /dev/null +++ b/backend/prisma/seed.ts @@ -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); + }); diff --git a/backend/scripts/test-smtp.mjs b/backend/scripts/test-smtp.mjs new file mode 100644 index 0000000..ff64132 --- /dev/null +++ b/backend/scripts/test-smtp.mjs @@ -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 -- "); + 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: "

This is a test email from the Alpinbet backend.

" +}); + +console.log("SMTP test email sent."); diff --git a/backend/scripts/with-db-url.mjs b/backend/scripts/with-db-url.mjs new file mode 100644 index 0000000..159a905 --- /dev/null +++ b/backend/scripts/with-db-url.mjs @@ -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 [...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); +}); diff --git a/backend/src/app.ts b/backend/src/app.ts new file mode 100644 index 0000000..e120d63 --- /dev/null +++ b/backend/src/app.ts @@ -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); diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts new file mode 100644 index 0000000..ab76923 --- /dev/null +++ b/backend/src/config/env.ts @@ -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(); + 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(); + } + + 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(); + } +}; + +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)); diff --git a/backend/src/db/prisma.ts b/backend/src/db/prisma.ts new file mode 100644 index 0000000..df5d23e --- /dev/null +++ b/backend/src/db/prisma.ts @@ -0,0 +1,4 @@ +import { PrismaClient } from "@prisma/client"; + +export const prisma = new PrismaClient(); + diff --git a/backend/src/index.ts b/backend/src/index.ts new file mode 100644 index 0000000..d1bfef7 --- /dev/null +++ b/backend/src/index.ts @@ -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); diff --git a/backend/src/lib/auth.ts b/backend/src/lib/auth.ts new file mode 100644 index 0000000..a67b3b0 --- /dev/null +++ b/backend/src/lib/auth.ts @@ -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; +} diff --git a/backend/src/lib/dedupe.ts b/backend/src/lib/dedupe.ts new file mode 100644 index 0000000..fb42395 --- /dev/null +++ b/backend/src/lib/dedupe.ts @@ -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(":"); +} + diff --git a/backend/src/lib/errors.ts b/backend/src/lib/errors.ts new file mode 100644 index 0000000..17bf016 --- /dev/null +++ b/backend/src/lib/errors.ts @@ -0,0 +1,9 @@ +export class HttpError extends Error { + statusCode: number; + + constructor(statusCode: number, message: string) { + super(message); + this.statusCode = statusCode; + } +} + diff --git a/backend/src/lib/mail.ts b/backend/src/lib/mail.ts new file mode 100644 index 0000000..8c771a2 --- /dev/null +++ b/backend/src/lib/mail.ts @@ -0,0 +1,86 @@ +import nodemailer from "nodemailer"; +import { env } from "../config/env.js"; + +let transporterPromise: Promise | null = null; +let verifyPromise: Promise | 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; + } +} diff --git a/backend/src/lib/native-push.ts b/backend/src/lib/native-push.ts new file mode 100644 index 0000000..cc5e98f --- /dev/null +++ b/backend/src/lib/native-push.ts @@ -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; + } +): Promise { + 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 + }; + } +} diff --git a/backend/src/lib/push.ts b/backend/src/lib/push.ts new file mode 100644 index 0000000..39e07fe --- /dev/null +++ b/backend/src/lib/push.ts @@ -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 +) { + 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; + } +} diff --git a/backend/src/lib/redis.ts b/backend/src/lib/redis.ts new file mode 100644 index 0000000..37b1e85 --- /dev/null +++ b/backend/src/lib/redis.ts @@ -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(); +} diff --git a/backend/src/lib/settlement.ts b/backend/src/lib/settlement.ts new file mode 100644 index 0000000..2459a23 --- /dev/null +++ b/backend/src/lib/settlement.ts @@ -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; +} + diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts new file mode 100644 index 0000000..4e7b95c --- /dev/null +++ b/backend/src/middleware/auth.ts @@ -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(); +} diff --git a/backend/src/middleware/error-handler.ts b/backend/src/middleware/error-handler.ts new file mode 100644 index 0000000..9a2e093 --- /dev/null +++ b/backend/src/middleware/error-handler.ts @@ -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: "Внутренняя ошибка сервера" }); +} diff --git a/backend/src/modules/admin/admin.routes.ts b/backend/src/modules/admin/admin.routes.ts new file mode 100644 index 0000000..4b5cfef --- /dev/null +++ b/backend/src/modules/admin/admin.routes.ts @@ -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 +) { + 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 | 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(); + + 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); +}); diff --git a/backend/src/modules/app-version/app-version.routes.ts b/backend/src/modules/app-version/app-version.routes.ts new file mode 100644 index 0000000..75dcbf8 --- /dev/null +++ b/backend/src/modules/app-version/app-version.routes.ts @@ -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 || "Доступна новая версия приложения" + }); +}); diff --git a/backend/src/modules/auth/auth.routes.ts b/backend/src/modules/auth/auth.routes.ts new file mode 100644 index 0000000..725aead --- /dev/null +++ b/backend/src/modules/auth/auth.routes.ts @@ -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) { + 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: ` +
+

Восстановление пароля

+

Вы запросили смену пароля для вашего аккаунта.

+

+ + Сбросить пароль + +

+

Или откройте ссылку вручную:

+

${resetUrl}

+

Ссылка действует ${env.PASSWORD_RESET_TTL_MINUTES} минут.

+
+ ` + }); +} + +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(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 + })) + }); +}); diff --git a/backend/src/modules/auth/auth.schemas.ts b/backend/src/modules/auth/auth.schemas.ts new file mode 100644 index 0000000..18f148d --- /dev/null +++ b/backend/src/modules/auth/auth.schemas.ts @@ -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"] + }); + diff --git a/backend/src/modules/internal/internal.routes.ts b/backend/src/modules/internal/internal.routes.ts new file mode 100644 index 0000000..3ec79c2 --- /dev/null +++ b/backend/src/modules/internal/internal.routes.ts @@ -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); + } +}); diff --git a/backend/src/modules/push/push.routes.ts b/backend/src/modules/push/push.routes.ts new file mode 100644 index 0000000..3e9a77f --- /dev/null +++ b/backend/src/modules/push/push.routes.ts @@ -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; + const nestedKeys = + payload.keys && typeof payload.keys === "object" ? (payload.keys as Record) : 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); + } +}); diff --git a/backend/src/modules/push/push.schemas.ts b/backend/src/modules/push/push.schemas.ts new file mode 100644 index 0000000..0c0ad6b --- /dev/null +++ b/backend/src/modules/push/push.schemas.ts @@ -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) +}); diff --git a/backend/src/modules/push/push.service.ts b/backend/src/modules/push/push.service.ts new file mode 100644 index 0000000..9ebcd86 --- /dev/null +++ b/backend/src/modules/push/push.service.ts @@ -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; + return payload.forecastInactive === true || payload.activeTab === 2 || payload.activeTab === "2"; +} + +function getAliasedBotName(rawPayload: Record | 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 + : 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; + 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 + : 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, + 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, + 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, + 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, + 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 + : 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 }; +} diff --git a/backend/src/modules/queues/push.queue.ts b/backend/src/modules/queues/push.queue.ts new file mode 100644 index 0000000..eca3e02 --- /dev/null +++ b/backend/src/modules/queues/push.queue.ts @@ -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(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); +} diff --git a/backend/src/modules/queues/queue.constants.ts b/backend/src/modules/queues/queue.constants.ts new file mode 100644 index 0000000..40d3a20 --- /dev/null +++ b/backend/src/modules/queues/queue.constants.ts @@ -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"; diff --git a/backend/src/modules/queues/queue.types.ts b/backend/src/modules/queues/queue.types.ts new file mode 100644 index 0000000..f22e0a9 --- /dev/null +++ b/backend/src/modules/queues/queue.types.ts @@ -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 | null; +}; + +export type SignalsSnapshotJobData = { + providerId: string; + items: QueueSignalPayload[]; + meta?: Record; + 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; diff --git a/backend/src/modules/queues/signals.queue.ts b/backend/src/modules/queues/signals.queue.ts new file mode 100644 index 0000000..715f67c --- /dev/null +++ b/backend/src/modules/queues/signals.queue.ts @@ -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(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); +} diff --git a/backend/src/modules/rss/rss.routes.ts b/backend/src/modules/rss/rss.routes.ts new file mode 100644 index 0000000..9a7ffbb --- /dev/null +++ b/backend/src/modules/rss/rss.routes.ts @@ -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("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +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 [ + "", + `${escapeXml(title)}`, + `${escapeXml(link)}`, + `${escapeXml(signal.id)}`, + `${toRfc822(signal.signalTime)}`, + `${escapeXml(description)}`, + "" + ].join(""); + }) + .join(""); + + const channelParts = [ + '', + '', + "", + "Betting Signals", + "Latest published betting signals", + `${escapeXml(siteUrl)}`, + `${toRfc822(latestDate)}`, + "ru" + ]; + + if (selfUrl) { + channelParts.push(``); + } + + channelParts.push(itemsXml, "", ""); + + res.setHeader("Content-Type", "application/rss+xml; charset=utf-8"); + res.send(channelParts.join("")); + } catch (error) { + next(error); + } +}); diff --git a/backend/src/modules/signals/parser-signals.service.ts b/backend/src/modules/signals/parser-signals.service.ts new file mode 100644 index 0000000..b52c1e1 --- /dev/null +++ b/backend/src/modules/signals/parser-signals.service.ts @@ -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>( + ` + 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 + }; +} diff --git a/backend/src/modules/signals/signals.routes.ts b/backend/src/modules/signals/signals.routes.ts new file mode 100644 index 0000000..b873303 --- /dev/null +++ b/backend/src/modules/signals/signals.routes.ts @@ -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(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) + : 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)); +}); diff --git a/backend/src/modules/signals/signals.schemas.ts b/backend/src/modules/signals/signals.schemas.ts new file mode 100644 index 0000000..6956d3a --- /dev/null +++ b/backend/src/modules/signals/signals.schemas.ts @@ -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) +}); diff --git a/backend/src/modules/signals/signals.service.ts b/backend/src/modules/signals/signals.service.ts new file mode 100644 index 0000000..0ee4145 --- /dev/null +++ b/backend/src/modules/signals/signals.service.ts @@ -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 | null; + published?: boolean; +}; + +function normalizeJson(value: Record | 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(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 & { 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}` + } + }); + } +} diff --git a/backend/src/modules/users/users.routes.ts b/backend/src/modules/users/users.routes.ts new file mode 100644 index 0000000..86610af --- /dev/null +++ b/backend/src/modules/users/users.routes.ts @@ -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) + })) + ); +}); diff --git a/backend/src/modules/workers/provider.interface.ts b/backend/src/modules/workers/provider.interface.ts new file mode 100644 index 0000000..a865749 --- /dev/null +++ b/backend/src/modules/workers/provider.interface.ts @@ -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; +}; + +export interface SignalProvider { + providerName: string; + fetchSignals(): Promise; +} + +export class StubProvider implements SignalProvider { + providerName = "stub"; + + async fetchSignals() { + return []; + } +} + diff --git a/backend/src/modules/workers/settlement.worker.ts b/backend/src/modules/workers/settlement.worker.ts new file mode 100644 index 0000000..815b78e --- /dev/null +++ b/backend/src/modules/workers/settlement.worker.ts @@ -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); +} + diff --git a/backend/src/types/web-push.d.ts b/backend/src/types/web-push.d.ts new file mode 100644 index 0000000..bec01c6 --- /dev/null +++ b/backend/src/types/web-push.d.ts @@ -0,0 +1,2 @@ +declare module "web-push"; + diff --git a/backend/src/workers/push.worker.ts b/backend/src/workers/push.worker.ts new file mode 100644 index 0000000..e4b3ea7 --- /dev/null +++ b/backend/src/workers/push.worker.ts @@ -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( + 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(); +}); diff --git a/backend/src/workers/signals.worker.ts b/backend/src/workers/signals.worker.ts new file mode 100644 index 0000000..babd888 --- /dev/null +++ b/backend/src/workers/signals.worker.ts @@ -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(); +}); diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..56f5215 --- /dev/null +++ b/backend/tsconfig.json @@ -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"] +} diff --git a/chat-service/Dockerfile b/chat-service/Dockerfile new file mode 100644 index 0000000..1945e84 --- /dev/null +++ b/chat-service/Dockerfile @@ -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"] diff --git a/chat-service/package.json b/chat-service/package.json new file mode 100644 index 0000000..b85872a --- /dev/null +++ b/chat-service/package.json @@ -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" + } +} diff --git a/chat-service/prisma/migrations/20260326130000_init_chat_service/migration.sql b/chat-service/prisma/migrations/20260326130000_init_chat_service/migration.sql new file mode 100644 index 0000000..a51b720 --- /dev/null +++ b/chat-service/prisma/migrations/20260326130000_init_chat_service/migration.sql @@ -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; diff --git a/chat-service/prisma/schema.prisma b/chat-service/prisma/schema.prisma new file mode 100644 index 0000000..4c33f7c --- /dev/null +++ b/chat-service/prisma/schema.prisma @@ -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]) +} diff --git a/chat-service/scripts/with-db-url.mjs b/chat-service/scripts/with-db-url.mjs new file mode 100644 index 0000000..b6a3311 --- /dev/null +++ b/chat-service/scripts/with-db-url.mjs @@ -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 [...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); +}); diff --git a/chat-service/src/app.ts b/chat-service/src/app.ts new file mode 100644 index 0000000..18f2198 --- /dev/null +++ b/chat-service/src/app.ts @@ -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); diff --git a/chat-service/src/config/env.ts b/chat-service/src/config/env.ts new file mode 100644 index 0000000..9e313fe --- /dev/null +++ b/chat-service/src/config/env.ts @@ -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); diff --git a/chat-service/src/db/prisma.ts b/chat-service/src/db/prisma.ts new file mode 100644 index 0000000..1bcd7f8 --- /dev/null +++ b/chat-service/src/db/prisma.ts @@ -0,0 +1,3 @@ +import { PrismaClient } from "@chat-service/prisma-client"; + +export const prisma = new PrismaClient(); diff --git a/chat-service/src/index.ts b/chat-service/src/index.ts new file mode 100644 index 0000000..627d680 --- /dev/null +++ b/chat-service/src/index.ts @@ -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); diff --git a/chat-service/src/lib/auth.ts b/chat-service/src/lib/auth.ts new file mode 100644 index 0000000..7d34da7 --- /dev/null +++ b/chat-service/src/lib/auth.ts @@ -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; +} diff --git a/chat-service/src/middleware/auth.ts b/chat-service/src/middleware/auth.ts new file mode 100644 index 0000000..d3cfca9 --- /dev/null +++ b/chat-service/src/middleware/auth.ts @@ -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(); +} diff --git a/chat-service/src/modules/support/support.routes.ts b/chat-service/src/modules/support/support.routes.ts new file mode 100644 index 0000000..ea525b8 --- /dev/null +++ b/chat-service/src/modules/support/support.routes.ts @@ -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); +}); diff --git a/chat-service/src/realtime.ts b/chat-service/src/realtime.ts new file mode 100644 index 0000000..50f6115 --- /dev/null +++ b/chat-service/src/realtime.ts @@ -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; + message?: Record; +}; + +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); +} diff --git a/chat-service/tsconfig.json b/chat-service/tsconfig.json new file mode 100644 index 0000000..7bc5580 --- /dev/null +++ b/chat-service/tsconfig.json @@ -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"] +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..cfd2578 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/docs/BACKUP.md b/docs/BACKUP.md new file mode 100644 index 0000000..da4b0a1 --- /dev/null +++ b/docs/BACKUP.md @@ -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 +``` diff --git a/docs/COMMERCIAL_PROPOSAL.md b/docs/COMMERCIAL_PROPOSAL.md new file mode 100644 index 0000000..d5bf234 --- /dev/null +++ b/docs/COMMERCIAL_PROPOSAL.md @@ -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-провайдера; +- доступ к ботам, список ботов и учетные данные предоставляются заказчиком. diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md new file mode 100644 index 0000000..a039179 --- /dev/null +++ b/docs/DEPLOY.md @@ -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 БД diff --git a/downloads-service/Dockerfile b/downloads-service/Dockerfile new file mode 100644 index 0000000..7dd4720 --- /dev/null +++ b/downloads-service/Dockerfile @@ -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"] diff --git a/downloads-service/package-lock.json b/downloads-service/package-lock.json new file mode 100644 index 0000000..292b800 --- /dev/null +++ b/downloads-service/package-lock.json @@ -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" + } + } +} diff --git a/downloads-service/package.json b/downloads-service/package.json new file mode 100644 index 0000000..f1eaf37 --- /dev/null +++ b/downloads-service/package.json @@ -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" + } +} diff --git a/downloads-service/server.mjs b/downloads-service/server.mjs new file mode 100644 index 0000000..4015bd2 --- /dev/null +++ b/downloads-service/server.mjs @@ -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}`); +}); diff --git a/downloads/.gitkeep b/downloads/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/downloads/.gitkeep @@ -0,0 +1 @@ + diff --git a/forecast-ocr-service/Dockerfile b/forecast-ocr-service/Dockerfile new file mode 100644 index 0000000..c2f8439 --- /dev/null +++ b/forecast-ocr-service/Dockerfile @@ -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"] diff --git a/forecast-ocr-service/eng.traineddata b/forecast-ocr-service/eng.traineddata new file mode 100644 index 0000000..6d11002 Binary files /dev/null and b/forecast-ocr-service/eng.traineddata differ diff --git a/forecast-ocr-service/package-lock.json b/forecast-ocr-service/package-lock.json new file mode 100644 index 0000000..bbb6d34 --- /dev/null +++ b/forecast-ocr-service/package-lock.json @@ -0,0 +1,1702 @@ +{ + "name": "forecast-ocr-service", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "forecast-ocr-service", + "version": "0.1.0", + "dependencies": { + "axios": "^1.8.4", + "express": "^4.21.2", + "multer": "^2.0.2", + "sharp": "^0.34.4", + "tesseract.js": "^6.0.1" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", + "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/bmp-js": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/bmp-js/-/bmp-js-0.1.0.tgz", + "integrity": "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/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.npmjs.org/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.npmjs.org/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/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/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.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/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.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/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.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/idb-keyval": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.2.tgz", + "integrity": "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==", + "license": "Apache-2.0" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/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.npmjs.org/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-url": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz", + "integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/multer": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz", + "integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/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.npmjs.org/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/opencollective-postinstall": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz", + "integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==", + "license": "MIT", + "bin": { + "opencollective-postinstall": "index.js" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/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/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "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.npmjs.org/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": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT" + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/tesseract.js": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tesseract.js/-/tesseract.js-6.0.1.tgz", + "integrity": "sha512-/sPvMvrCtgxnNRCjbTYbr7BRu0yfWDsMZQ2a/T5aN/L1t8wUQN6tTWv6p6FwzpoEBA0jrN2UD2SX4QQFRdoDbA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "bmp-js": "^0.1.0", + "idb-keyval": "^6.2.0", + "is-url": "^1.2.4", + "node-fetch": "^2.6.9", + "opencollective-postinstall": "^2.0.3", + "regenerator-runtime": "^0.13.3", + "tesseract.js-core": "^6.0.0", + "wasm-feature-detect": "^1.2.11", + "zlibjs": "^0.3.1" + } + }, + "node_modules/tesseract.js-core": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/tesseract.js-core/-/tesseract.js-core-6.1.2.tgz", + "integrity": "sha512-pv4GjmramjdObhDyR1q85Td8X60Puu/lGQn7Kw2id05LLgHhAcWgnz6xSdMCSxBMWjQDmMyDXPTC2aqADdpiow==", + "license": "Apache-2.0" + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/wasm-feature-detect": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.8.0.tgz", + "integrity": "sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==", + "license": "Apache-2.0" + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/zlibjs": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/zlibjs/-/zlibjs-0.3.1.tgz", + "integrity": "sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==", + "license": "MIT", + "engines": { + "node": "*" + } + } + } +} diff --git a/forecast-ocr-service/package.json b/forecast-ocr-service/package.json new file mode 100644 index 0000000..74bb6d4 --- /dev/null +++ b/forecast-ocr-service/package.json @@ -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" + } +} diff --git a/forecast-ocr-service/rus.traineddata b/forecast-ocr-service/rus.traineddata new file mode 100644 index 0000000..ec0813a Binary files /dev/null and b/forecast-ocr-service/rus.traineddata differ diff --git a/forecast-ocr-service/src/index.js b/forecast-ocr-service/src/index.js new file mode 100644 index 0000000..85e90df --- /dev/null +++ b/forecast-ocr-service/src/index.js @@ -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}`); +}); diff --git a/forecast-ocr-service/src/ocr.js b/forecast-ocr-service/src/ocr.js new file mode 100644 index 0000000..16273c4 --- /dev/null +++ b/forecast-ocr-service/src/ocr.js @@ -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 +}; diff --git a/forecast-ocr-service/tmp-forecast-handicap.png b/forecast-ocr-service/tmp-forecast-handicap.png new file mode 100644 index 0000000..11b01c8 Binary files /dev/null and b/forecast-ocr-service/tmp-forecast-handicap.png differ diff --git a/forecast-ocr-service/tmp-forecast-test-preprocessed.png b/forecast-ocr-service/tmp-forecast-test-preprocessed.png new file mode 100644 index 0000000..dd76270 Binary files /dev/null and b/forecast-ocr-service/tmp-forecast-test-preprocessed.png differ diff --git a/forecast-ocr-service/tmp-forecast-test.png b/forecast-ocr-service/tmp-forecast-test.png new file mode 100644 index 0000000..5b38334 Binary files /dev/null and b/forecast-ocr-service/tmp-forecast-test.png differ diff --git a/frontend/.tmp/launcher-source.png b/frontend/.tmp/launcher-source.png new file mode 100644 index 0000000..b652bc6 Binary files /dev/null and b/frontend/.tmp/launcher-source.png differ diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..d568859 --- /dev/null +++ b/frontend/Dockerfile @@ -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"] diff --git a/frontend/android/.gitignore b/frontend/android/.gitignore new file mode 100644 index 0000000..48354a3 --- /dev/null +++ b/frontend/android/.gitignore @@ -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 diff --git a/frontend/android/.idea/.gitignore b/frontend/android/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/frontend/android/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/frontend/android/.idea/AndroidProjectSystem.xml b/frontend/android/.idea/AndroidProjectSystem.xml new file mode 100644 index 0000000..4a53bee --- /dev/null +++ b/frontend/android/.idea/AndroidProjectSystem.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/frontend/android/.idea/compiler.xml b/frontend/android/.idea/compiler.xml new file mode 100644 index 0000000..b86273d --- /dev/null +++ b/frontend/android/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/frontend/android/.idea/deploymentTargetSelector.xml b/frontend/android/.idea/deploymentTargetSelector.xml new file mode 100644 index 0000000..b268ef3 --- /dev/null +++ b/frontend/android/.idea/deploymentTargetSelector.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/frontend/android/.idea/migrations.xml b/frontend/android/.idea/migrations.xml new file mode 100644 index 0000000..f8051a6 --- /dev/null +++ b/frontend/android/.idea/migrations.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/frontend/android/.idea/misc.xml b/frontend/android/.idea/misc.xml new file mode 100644 index 0000000..b2c751a --- /dev/null +++ b/frontend/android/.idea/misc.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/frontend/android/.idea/runConfigurations.xml b/frontend/android/.idea/runConfigurations.xml new file mode 100644 index 0000000..16660f1 --- /dev/null +++ b/frontend/android/.idea/runConfigurations.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/frontend/android/app/.gitignore b/frontend/android/app/.gitignore new file mode 100644 index 0000000..043df80 --- /dev/null +++ b/frontend/android/app/.gitignore @@ -0,0 +1,2 @@ +/build/* +!/build/.npmkeep diff --git a/frontend/android/app/build.gradle b/frontend/android/app/build.gradle new file mode 100644 index 0000000..9b032a9 --- /dev/null +++ b/frontend/android/app/build.gradle @@ -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") +} diff --git a/frontend/android/app/capacitor.build.gradle b/frontend/android/app/capacitor.build.gradle new file mode 100644 index 0000000..c92f7b4 --- /dev/null +++ b/frontend/android/app/capacitor.build.gradle @@ -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() +} diff --git a/frontend/android/app/proguard-rules.pro b/frontend/android/app/proguard-rules.pro new file mode 100644 index 0000000..f1b4245 --- /dev/null +++ b/frontend/android/app/proguard-rules.pro @@ -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 diff --git a/frontend/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java b/frontend/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java new file mode 100644 index 0000000..f2c2217 --- /dev/null +++ b/frontend/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java @@ -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 Testing documentation + */ +@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()); + } +} diff --git a/frontend/android/app/src/main/AndroidManifest.xml b/frontend/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a467093 --- /dev/null +++ b/frontend/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/android/app/src/main/java/com/alpinbet/signals/MainActivity.java b/frontend/android/app/src/main/java/com/alpinbet/signals/MainActivity.java new file mode 100644 index 0000000..ac74df9 --- /dev/null +++ b/frontend/android/app/src/main/java/com/alpinbet/signals/MainActivity.java @@ -0,0 +1,5 @@ +package com.alpinbet.app; + +import com.getcapacitor.BridgeActivity; + +public class MainActivity extends BridgeActivity {} diff --git a/frontend/android/app/src/main/res/drawable-land-hdpi/splash.png b/frontend/android/app/src/main/res/drawable-land-hdpi/splash.png new file mode 100644 index 0000000..e31573b Binary files /dev/null and b/frontend/android/app/src/main/res/drawable-land-hdpi/splash.png differ diff --git a/frontend/android/app/src/main/res/drawable-land-mdpi/splash.png b/frontend/android/app/src/main/res/drawable-land-mdpi/splash.png new file mode 100644 index 0000000..f7a6492 Binary files /dev/null and b/frontend/android/app/src/main/res/drawable-land-mdpi/splash.png differ diff --git a/frontend/android/app/src/main/res/drawable-land-xhdpi/splash.png b/frontend/android/app/src/main/res/drawable-land-xhdpi/splash.png new file mode 100644 index 0000000..8077255 Binary files /dev/null and b/frontend/android/app/src/main/res/drawable-land-xhdpi/splash.png differ diff --git a/frontend/android/app/src/main/res/drawable-land-xxhdpi/splash.png b/frontend/android/app/src/main/res/drawable-land-xxhdpi/splash.png new file mode 100644 index 0000000..14c6c8f Binary files /dev/null and b/frontend/android/app/src/main/res/drawable-land-xxhdpi/splash.png differ diff --git a/frontend/android/app/src/main/res/drawable-land-xxxhdpi/splash.png b/frontend/android/app/src/main/res/drawable-land-xxxhdpi/splash.png new file mode 100644 index 0000000..244ca25 Binary files /dev/null and b/frontend/android/app/src/main/res/drawable-land-xxxhdpi/splash.png differ diff --git a/frontend/android/app/src/main/res/drawable-port-hdpi/splash.png b/frontend/android/app/src/main/res/drawable-port-hdpi/splash.png new file mode 100644 index 0000000..74faaa5 Binary files /dev/null and b/frontend/android/app/src/main/res/drawable-port-hdpi/splash.png differ diff --git a/frontend/android/app/src/main/res/drawable-port-mdpi/splash.png b/frontend/android/app/src/main/res/drawable-port-mdpi/splash.png new file mode 100644 index 0000000..e944f4a Binary files /dev/null and b/frontend/android/app/src/main/res/drawable-port-mdpi/splash.png differ diff --git a/frontend/android/app/src/main/res/drawable-port-xhdpi/splash.png b/frontend/android/app/src/main/res/drawable-port-xhdpi/splash.png new file mode 100644 index 0000000..564a82f Binary files /dev/null and b/frontend/android/app/src/main/res/drawable-port-xhdpi/splash.png differ diff --git a/frontend/android/app/src/main/res/drawable-port-xxhdpi/splash.png b/frontend/android/app/src/main/res/drawable-port-xxhdpi/splash.png new file mode 100644 index 0000000..bfabe68 Binary files /dev/null and b/frontend/android/app/src/main/res/drawable-port-xxhdpi/splash.png differ diff --git a/frontend/android/app/src/main/res/drawable-port-xxxhdpi/splash.png b/frontend/android/app/src/main/res/drawable-port-xxxhdpi/splash.png new file mode 100644 index 0000000..6929071 Binary files /dev/null and b/frontend/android/app/src/main/res/drawable-port-xxxhdpi/splash.png differ diff --git a/frontend/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/frontend/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..c7bd21d --- /dev/null +++ b/frontend/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + diff --git a/frontend/android/app/src/main/res/drawable/ic_launcher_background.xml b/frontend/android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..b9fa9d6 --- /dev/null +++ b/frontend/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,10 @@ + + + + diff --git a/frontend/android/app/src/main/res/drawable/splash.png b/frontend/android/app/src/main/res/drawable/splash.png new file mode 100644 index 0000000..f7a6492 Binary files /dev/null and b/frontend/android/app/src/main/res/drawable/splash.png differ diff --git a/frontend/android/app/src/main/res/layout/activity_main.xml b/frontend/android/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..b5ad138 --- /dev/null +++ b/frontend/android/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/frontend/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..80b730f --- /dev/null +++ b/frontend/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/frontend/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..80b730f --- /dev/null +++ b/frontend/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/frontend/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..76cb0d7 Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/frontend/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/frontend/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..280f5b5 Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/frontend/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/frontend/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..76cb0d7 Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/frontend/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/frontend/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..c13c33a Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/frontend/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/frontend/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..ea4a936 Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/frontend/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/frontend/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..c13c33a Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/frontend/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/frontend/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..243549f Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/frontend/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/frontend/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..00e5b15 Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/frontend/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/frontend/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..243549f Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/frontend/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/frontend/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..5ff74be Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/frontend/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/frontend/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..a583b78 Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/frontend/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/frontend/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..5ff74be Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/frontend/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/frontend/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..dffbc0a Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/frontend/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/frontend/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..0c3326e Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/frontend/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/frontend/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..dffbc0a Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/frontend/android/app/src/main/res/values/ic_launcher_background.xml b/frontend/android/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..f42ada6 --- /dev/null +++ b/frontend/android/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #FFFFFF + diff --git a/frontend/android/app/src/main/res/values/strings.xml b/frontend/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..2b851bd --- /dev/null +++ b/frontend/android/app/src/main/res/values/strings.xml @@ -0,0 +1,7 @@ + + + Антигол + Антигол + com.alpinbet.app + com.alpinbet.app + diff --git a/frontend/android/app/src/main/res/values/styles.xml b/frontend/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..fffbd6e --- /dev/null +++ b/frontend/android/app/src/main/res/values/styles.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + diff --git a/frontend/android/app/src/main/res/xml/file_paths.xml b/frontend/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..bd0c4d8 --- /dev/null +++ b/frontend/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java b/frontend/android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java new file mode 100644 index 0000000..0297327 --- /dev/null +++ b/frontend/android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java @@ -0,0 +1,18 @@ +package com.getcapacitor.myapp; + +import static org.junit.Assert.*; + +import org.junit.Test; + +/** + * Example local unit test, which will execute on the development machine (host). + * + * @see Testing documentation + */ +public class ExampleUnitTest { + + @Test + public void addition_isCorrect() throws Exception { + assertEquals(4, 2 + 2); + } +} diff --git a/frontend/android/build.gradle b/frontend/android/build.gradle new file mode 100644 index 0000000..f1b3b0e --- /dev/null +++ b/frontend/android/build.gradle @@ -0,0 +1,29 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. + +buildscript { + + repositories { + google() + mavenCentral() + } + dependencies { + classpath 'com.android.tools.build:gradle:8.7.2' + classpath 'com.google.gms:google-services:4.4.2' + + // NOTE: Do not place your application dependencies here; they belong + // in the individual module build.gradle files + } +} + +apply from: "variables.gradle" + +allprojects { + repositories { + google() + mavenCentral() + } +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/frontend/android/capacitor.settings.gradle b/frontend/android/capacitor.settings.gradle new file mode 100644 index 0000000..5c5928a --- /dev/null +++ b/frontend/android/capacitor.settings.gradle @@ -0,0 +1,18 @@ +// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN +include ':capacitor-android' +project(':capacitor-android').projectDir = new File('../../node_modules/@capacitor/android/capacitor') + +include ':capacitor-app' +project(':capacitor-app').projectDir = new File('../../node_modules/@capacitor/app/android') + +include ':capacitor-device' +project(':capacitor-device').projectDir = new File('../../node_modules/@capacitor/device/android') + +include ':capacitor-preferences' +project(':capacitor-preferences').projectDir = new File('../../node_modules/@capacitor/preferences/android') + +include ':capacitor-push-notifications' +project(':capacitor-push-notifications').projectDir = new File('../../node_modules/@capacitor/push-notifications/android') + +include ':capacitor-splash-screen' +project(':capacitor-splash-screen').projectDir = new File('../../node_modules/@capacitor/splash-screen/android') diff --git a/frontend/android/gradle.properties b/frontend/android/gradle.properties new file mode 100644 index 0000000..6891f34 --- /dev/null +++ b/frontend/android/gradle.properties @@ -0,0 +1,27 @@ +# Project-wide Gradle settings. + +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. + +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html + +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx1536m + +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true + +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true + +ALPINBET_UPLOAD_STORE_FILE=../keystore/alpinbet-release.jks +ALPINBET_UPLOAD_KEY_ALIAS=alpinbet +ALPINBET_UPLOAD_STORE_PASSWORD=talorr31 +ALPINBET_UPLOAD_KEY_PASSWORD=talorr31 diff --git a/frontend/android/gradle/wrapper/gradle-wrapper.jar b/frontend/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..a4b76b9 Binary files /dev/null and b/frontend/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/frontend/android/gradle/wrapper/gradle-wrapper.properties b/frontend/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..c1d5e01 --- /dev/null +++ b/frontend/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/frontend/android/gradlew b/frontend/android/gradlew new file mode 100644 index 0000000..f5feea6 --- /dev/null +++ b/frontend/android/gradlew @@ -0,0 +1,252 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/frontend/android/gradlew.bat b/frontend/android/gradlew.bat new file mode 100644 index 0000000..9d21a21 --- /dev/null +++ b/frontend/android/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/frontend/android/settings.gradle b/frontend/android/settings.gradle new file mode 100644 index 0000000..3b4431d --- /dev/null +++ b/frontend/android/settings.gradle @@ -0,0 +1,5 @@ +include ':app' +include ':capacitor-cordova-android-plugins' +project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/') + +apply from: 'capacitor.settings.gradle' \ No newline at end of file diff --git a/frontend/android/variables.gradle b/frontend/android/variables.gradle new file mode 100644 index 0000000..2c8e408 --- /dev/null +++ b/frontend/android/variables.gradle @@ -0,0 +1,16 @@ +ext { + minSdkVersion = 23 + compileSdkVersion = 35 + targetSdkVersion = 35 + androidxActivityVersion = '1.9.2' + androidxAppCompatVersion = '1.7.0' + androidxCoordinatorLayoutVersion = '1.2.0' + androidxCoreVersion = '1.15.0' + androidxFragmentVersion = '1.8.4' + coreSplashScreenVersion = '1.0.1' + androidxWebkitVersion = '1.12.1' + junitVersion = '4.13.2' + androidxJunitVersion = '1.2.1' + androidxEspressoCoreVersion = '3.6.1' + cordovaAndroidVersion = '10.1.1' +} \ No newline at end of file diff --git a/frontend/app.vue b/frontend/app.vue new file mode 100644 index 0000000..96e4c4f --- /dev/null +++ b/frontend/app.vue @@ -0,0 +1,382 @@ + + + diff --git a/frontend/assets/css/main.css b/frontend/assets/css/main.css new file mode 100644 index 0000000..4b85017 --- /dev/null +++ b/frontend/assets/css/main.css @@ -0,0 +1,1792 @@ +@import "tailwindcss"; + +@layer base { + :root { + --bg: #f5f7fb; + --surface: #ffffff; + --surface-strong: #ffffff; + --surface-soft: #eef2f8; + --border: #dfe7f3; + --text: #1f2a44; + --muted: #6b7280; + --accent: #10b981; + --accent-strong: #059669; + --success-bg: #dcfce7; + --success-text: #166534; + --danger-bg: #fee2e2; + --danger-text: #b91c1c; + --warning-bg: #fef3c7; + --warning-text: #92400e; + font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; + } + + :root[data-theme="dark"] { + --bg: #111827; + --surface: #182131; + --surface-strong: #101826; + --surface-soft: #243247; + --border: #314155; + --text: #e5eefc; + --muted: #9db0cc; + --accent: #34d399; + --accent-strong: #10b981; + --success-bg: #14532d; + --success-text: #dcfce7; + --danger-bg: #7f1d1d; + --danger-text: #fee2e2; + --warning-bg: #78350f; + --warning-text: #fef3c7; + } + + * { + box-sizing: border-box; + } + + html, + body, + #__nuxt { + min-height: 100vh; + } + + html { + background-color: var(--bg); + } + + body { + @apply m-0 antialiased; + background-color: var(--bg); + color: var(--text); + } + + a { + color: inherit; + text-decoration: none; + } + + button, + input, + select, + textarea { + font: inherit; + } + + button { + @apply inline-flex items-center justify-center rounded-2xl px-4 py-3 text-sm font-semibold transition disabled:cursor-not-allowed disabled:opacity-60; + border: 1px solid transparent; + background-color: var(--accent); + color: white; + } + + button:hover:not(:disabled) { + background-color: var(--accent-strong); + } + + input, + select, + textarea { + @apply mt-1.5 w-full rounded-2xl px-4 py-3 text-sm outline-none transition; + border: 1px solid var(--border); + background-color: var(--surface); + color: var(--text); + } + + input::placeholder, + textarea::placeholder { + color: var(--muted); + } + + input:focus, + select:focus, + textarea:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent); + } +} + +@layer components { + .sakai-shell { + @apply min-h-screen; + background: + linear-gradient(180deg, color-mix(in srgb, var(--accent) 6%, transparent), transparent 24%), + var(--bg); + } + + .sakai-main { + @apply min-h-screen; + } + + .sakai-shell--guest .sakai-main { + @apply mx-auto w-full max-w-7xl; + } + + .sakai-sidebar { + @apply hidden; + } + + .sakai-topbar { + @apply flex flex-col gap-4 px-4 py-4; + } + + .sakai-topbar--guest { + @apply py-6; + } + + .sakai-topbar__headline { + @apply grid gap-1; + } + + .sakai-topbar__headline h1 { + @apply m-0 text-2xl font-semibold; + } + + .sakai-topbar__headline p { + @apply m-0 text-sm; + color: var(--muted); + } + + .sakai-topbar__actions { + @apply flex flex-wrap items-center gap-3; + } + + .sakai-user-chip { + @apply inline-flex items-center gap-3 rounded-2xl border px-3 py-2; + border-color: var(--border); + background-color: var(--surface); + } + + .sakai-user-chip strong, + .sakai-user-chip span { + @apply block; + } + + .sakai-user-chip span { + @apply text-xs; + color: var(--muted); + } + + .sakai-content { + @apply px-4 pb-24; + } + + .sakai-shell--guest .sakai-content { + @apply pb-10; + } + + .sakai-bottom-bar { + @apply fixed inset-x-4 bottom-4 z-40 flex items-center justify-around rounded-[22px] border px-2 py-2; + border-color: color-mix(in srgb, var(--border) 85%, transparent); + background-color: color-mix(in srgb, var(--surface) 88%, transparent); + box-shadow: 0 12px 32px color-mix(in srgb, var(--text) 12%, transparent); + backdrop-filter: blur(18px); + } + + .sakai-bottom-bar__item { + @apply flex min-w-0 flex-1 flex-col items-center gap-1 rounded-2xl px-2 py-2 text-center text-[0.7rem] font-medium transition; + color: var(--muted); + } + + .sakai-bottom-bar__item i { + @apply text-base; + } + + .sakai-bottom-bar__item--active { + background-color: color-mix(in srgb, var(--accent) 12%, var(--surface-soft)); + color: var(--text); + } + + .sakai-page { + @apply grid gap-4; + } + + .sakai-hero-card { + @apply flex flex-col gap-4 rounded-[24px] border p-5; + border-color: var(--border); + background: + radial-gradient(circle at top right, color-mix(in srgb, var(--accent) 10%, transparent), transparent 28%), + var(--surface); + box-shadow: 0 10px 30px color-mix(in srgb, var(--text) 4%, transparent); + } + + .sakai-hero-card h2 { + @apply m-0 text-3xl font-semibold; + } + + .sakai-hero-card p { + @apply m-0 text-sm leading-6; + color: var(--muted); + } + + .sakai-hero-card__tags { + @apply flex flex-wrap items-center gap-2; + } + + .sakai-section-label { + @apply mb-2 inline-block text-xs font-semibold uppercase tracking-[0.18em]; + color: var(--muted); + } + + .sakai-bot-grid { + @apply grid gap-4; + grid-template-columns: repeat(1, minmax(0, 1fr)); + } + + .sakai-bot-card { + @apply overflow-hidden aspect-square rounded-[24px]; + border: 1px solid var(--border); + box-shadow: 0 12px 28px color-mix(in srgb, var(--text) 5%, transparent); + } + + .sakai-bot-card .p-card-body, + .sakai-filter-card .p-card-body, + .sakai-signal-panel .p-card-body, + .sakai-summary-panel .p-card-body { + @apply p-0; + } + + .sakai-bot-card .p-card-content, + .sakai-filter-card .p-card-content, + .sakai-signal-panel .p-card-content, + .sakai-summary-panel .p-card-content { + @apply p-0; + } + + .sakai-bot-card .p-card-content { + @apply grid gap-4 p-5; + } + + .sakai-bot-card__top { + @apply flex items-center justify-between gap-3; + } + + .sakai-bot-card__icon { + @apply grid h-12 w-12 place-items-center rounded-2xl; + background-color: color-mix(in srgb, var(--accent) 12%, var(--surface-soft)); + color: var(--accent-strong); + } + + .sakai-bot-card__body { + @apply grid gap-2; + } + + .sakai-bot-card__eyebrow { + @apply text-xs font-semibold uppercase tracking-[0.18em]; + color: var(--muted); + } + + .sakai-bot-card__body h3 { + @apply m-0 text-2xl font-semibold leading-tight; + } + + .sakai-bot-card__body p { + @apply m-0 text-sm leading-6; + color: var(--muted); + } + + .sakai-bot-card__loading { + @apply grid gap-4; + } + + .sakai-bot-card__link { + @apply block; + } + + .sakai-page-back { + @apply mb-3; + } + + .sakai-filter-card, + .sakai-signal-panel, + .sakai-summary-panel { + @apply rounded-[24px]; + border: 1px solid var(--border); + box-shadow: 0 10px 30px color-mix(in srgb, var(--text) 4%, transparent); + } + + .sakai-filter-card .p-card-content { + @apply p-5; + } + + .sakai-filter-grid { + @apply grid gap-4; + } + + .sakai-field { + @apply grid gap-2; + } + + .sakai-field label { + @apply text-sm font-medium; + color: var(--text); + } + + .sakai-tab-scroll { + position: relative; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: thin; + overscroll-behavior-x: contain; + -webkit-overflow-scrolling: touch; + padding-bottom: 0.125rem; + } + + .sakai-tab-scroll::before, + .sakai-tab-scroll::after { + content: ""; + position: sticky; + top: 0; + bottom: 0; + width: 1rem; + display: block; + pointer-events: none; + z-index: 1; + } + + .sakai-tab-scroll::before { + left: 0; + float: left; + margin-right: -1rem; + background: linear-gradient(90deg, var(--surface-strong), color-mix(in srgb, var(--surface-strong) 0%, transparent)); + } + + .sakai-tab-scroll::after { + right: 0; + float: right; + margin-left: -1rem; + background: linear-gradient(270deg, var(--surface-strong), color-mix(in srgb, var(--surface-strong) 0%, transparent)); + } + + .sakai-tab-scroll .p-selectbutton { + display: inline-flex; + min-width: max-content; + flex-wrap: nowrap; + gap: 0.5rem; + padding-inline: 0.125rem; + } + + .sakai-tab-scroll .p-togglebutton { + flex: 0 0 auto; + white-space: nowrap; + } + + .sakai-signal-layout { + @apply grid gap-4; + } + + .sakai-signal-layout--detail { + @apply items-start; + } + + .sakai-signal-detail-main, + .sakai-signal-detail-aside { + @apply grid gap-4; + } + + .sakai-signal-panel .p-card-title, + .sakai-summary-panel .p-card-title { + @apply text-2xl font-semibold; + color: var(--text); + } + + .sakai-signal-panel .p-card-subtitle { + color: var(--muted); + } + + .sakai-signal-panel .p-card-caption, + .sakai-summary-panel .p-card-caption { + @apply px-5 pt-5; + } + + .sakai-signal-panel .p-card-content, + .sakai-summary-panel .p-card-content { + @apply p-5 pt-4; + } + + .sakai-signal-list { + @apply grid gap-3; + } + + .sakai-signal-link { + @apply block rounded-[20px] no-underline transition-transform duration-150; + color: inherit; + } + + .sakai-signal-link:hover { + @apply -translate-y-0.5; + } + + .sakai-signal-link:focus-visible { + @apply outline-none; + } + + .sakai-signal-link:focus-visible .sakai-signal-card { + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 35%, transparent); + } + + .sakai-signal-skeleton { + @apply grid gap-3 rounded-2xl border p-4; + border-color: var(--border); + background-color: var(--surface-soft); + } + + .sakai-signal-card { + @apply flex flex-col gap-4 rounded-[20px] border p-4; + border-color: var(--border); + background-color: color-mix(in srgb, var(--surface) 92%, var(--surface-soft)); + } + + .sakai-signal-card__main { + @apply grid gap-3; + } + + .sakai-signal-card__meta { + @apply flex flex-wrap gap-3 text-sm; + color: var(--muted); + } + + .sakai-signal-card__meta span { + @apply inline-flex items-center gap-2; + } + + .sakai-signal-card__teams { + @apply grid gap-1 text-lg; + } + + .sakai-signal-card__actions { + @apply flex; + } + + .sakai-copy-button { + @apply inline-flex items-center justify-center gap-2 shrink-0 rounded-full; + width: 2.25rem; + height: 2.25rem; + border-color: var(--border); + background-color: var(--surface); + color: var(--muted); + } + + .sakai-copy-button--wide { + @apply justify-center flex rounded-2xl px-4 py-3 text-sm font-semibold; + width: 100%; + min-height: 3.25rem; + } + + .sakai-copy-button:hover:not(:disabled) { + background-color: color-mix(in srgb, var(--accent) 10%, var(--surface)); + color: var(--text); + } + + .sakai-signal-card__market { + @apply flex flex-wrap gap-2 text-sm; + color: var(--muted); + } + + .sakai-signal-card__market span { + @apply inline-flex items-center rounded-full px-3 py-1; + background-color: var(--surface-soft); + } + + .sakai-signal-card__forecast { + @apply overflow-hidden rounded-2xl; + /* background-color: color-mix(in srgb, var(--accent) 10%, var(--surface-soft)); */ + color: var(--text); + width: 100%; + min-width: 0; + } + + :root[data-theme="dark"] .sakai-signal-card__forecast { + background-color: rgb(255 255 255 / 0.8); + } + + .sakai-signal-card__forecast-image { + @apply block h-auto w-full; + max-height: 220px; + object-fit: cover; + } + + :root[data-theme="dark"] .sakai-signal-card__forecast-image { + /* filter: grayscale(1) brightness(1.18) contrast(1.05); */ + } + + .sakai-signal-card__forecast-text { + @apply m-0 flex min-h-[3rem] items-center rounded-2xl px-4 py-3 text-sm font-semibold leading-6; + background: + linear-gradient(135deg, color-mix(in srgb, var(--accent) 10%, transparent), transparent 60%), + color-mix(in srgb, var(--surface-soft) 92%, white 8%); + color: var(--text); + word-break: break-word; + } + + .sakai-signal-card__forecast-text--empty { + color: var(--muted); + font-weight: 500; + } + + .sakai-signal-card__side { + @apply flex flex-wrap items-center justify-between gap-3; + } + + .sakai-signal-card__side > .sakai-signal-card__forecast { + flex: 1 0 100%; + } + + .sakai-signal-card__odds { + @apply rounded-2xl px-4 py-2 text-lg font-bold; + background-color: color-mix(in srgb, var(--accent) 10%, var(--surface-soft)); + color: var(--text); + } + + .sakai-signal-card--detail { + @apply gap-5; + } + + .sakai-signal-card__side--detail { + @apply items-stretch; + } + + .sakai-signal-card__forecast-image--detail { + max-height: 26rem; + } + + .sakai-signal-detail-loading { + @apply grid; + } + + .sakai-signal-detail-skeleton { + @apply grid gap-4; + } + + .sakai-signal-detail__timezone { + @apply m-0 text-sm; + color: var(--muted); + } + + .sakai-signal-detail__status-row { + @apply flex flex-wrap items-center justify-between gap-3; + } + + .sakai-signal-detail-grid { + @apply grid gap-3; + } + + .sakai-signal-detail-item { + @apply flex items-center justify-between gap-4 rounded-2xl border px-4 py-3; + border-color: var(--border); + background-color: color-mix(in srgb, var(--surface) 94%, var(--surface-soft)); + } + + .sakai-signal-detail-item span { + @apply text-sm; + color: var(--muted); + } + + .sakai-signal-detail-item strong { + @apply text-right font-semibold; + color: var(--text); + } + + .sakai-signal-detail-note { + @apply mt-4 rounded-[20px] border p-4; + border-color: var(--border); + background-color: color-mix(in srgb, var(--surface) 92%, var(--surface-soft)); + } + + .sakai-signal-detail-note span { + @apply text-sm font-semibold uppercase tracking-[0.16em]; + color: var(--muted); + } + + .sakai-signal-detail-note p { + @apply mb-0 mt-3 text-sm leading-6; + color: var(--text); + } + + .sakai-summary-panel--sticky { + position: static; + } + + .sakai-summary-list { + @apply grid gap-3; + } + + .sakai-summary-item { + @apply flex items-center justify-between rounded-2xl border px-4 py-3; + border-color: var(--border); + background-color: color-mix(in srgb, var(--surface) 94%, var(--surface-soft)); + } + + .sakai-summary-item span { + color: var(--muted); + } + + .sakai-summary-item strong { + @apply text-xl font-semibold; + } + + .app-shell { + @apply min-h-screen; + background-color: var(--bg); + } + + .app-shell--single { + @apply mx-auto flex min-h-screen w-full max-w-7xl flex-col px-4 py-4; + } + + .brand { + @apply text-xl font-extrabold uppercase tracking-[0.18em]; + } + + .topbar { + @apply mb-4 flex flex-col gap-4 rounded-[28px] border p-4; + border-color: var(--border); + background-color: var(--surface-strong); + } + + .topbar__brand { + @apply grid gap-1; + } + + .topbar__subtitle { + @apply text-sm; + color: var(--muted); + } + + .topbar__actions { + @apply flex flex-col gap-3; + } + + .topbar__nav { + @apply flex flex-wrap gap-2; + } + + .topbar__extras { + @apply flex flex-col gap-3; + } + + .user-chip { + @apply grid gap-1 rounded-2xl border px-4 py-3 text-sm; + border-color: var(--border); + background-color: var(--surface); + } + + .topbar__link, + .theme-toggle, + button.secondary, + .sort-toggle { + @apply rounded-2xl border px-4 py-3 text-sm font-medium; + border-color: var(--border); + background-color: var(--surface); + color: var(--text); + } + + .topbar__link--accent { + border-color: color-mix(in srgb, var(--accent) 35%, var(--border)); + background-color: color-mix(in srgb, var(--accent) 14%, var(--surface)); + } + + .topbar__link:hover, + .theme-toggle:hover, + button.secondary:hover, + .sort-toggle:hover:not(:disabled) { + background-color: var(--surface-soft); + } + + .content--single { + @apply flex-1; + } + + .push-consent, + .panel, + .signal-card { + @apply rounded-[28px] border p-4; + border-color: var(--border); + background-color: var(--surface-strong); + } + + .push-consent { + @apply mb-4 flex flex-col gap-4; + margin-top: calc(0.5rem + env(safe-area-inset-top, 0px)); + } + + .app-update { + @apply mb-4; + } + + .app-update--blocking { + @apply fixed inset-0 z-[120] flex items-center justify-center p-5; + background: color-mix(in srgb, var(--bg) 84%, black 16%); + } + + .app-update--blocking .push-consent__content, + .app-update--blocking .push-consent__actions { + width: min(100%, 26rem); + } + + .app-update--blocking .push-consent__content, + .app-update--blocking .push-consent__actions button { + max-width: 26rem; + } + + .app-update--blocking::before { + content: ""; + position: absolute; + inset: 0; + backdrop-filter: blur(10px); + } + + .app-update--blocking .push-consent__content, + .app-update--blocking .push-consent__actions { + position: relative; + z-index: 1; + } + + .app-update--blocking .push-consent__content { + @apply rounded-[28px] border p-5; + border-color: var(--border); + background-color: var(--surface-strong); + } + + .app-update--blocking .push-consent__actions { + @apply mt-4 flex flex-col gap-3; + } + + .push-consent--install { + background-color: color-mix(in srgb, var(--warning-bg) 16%, var(--surface-strong)); + } + + .push-consent__content { + @apply grid gap-2; + } + + .push-consent__content p, + .push-consent__message { + @apply m-0 text-sm; + color: var(--muted); + } + + .push-consent__hint { + color: var(--warning-text); + } + + .push-consent__actions { + @apply flex flex-col gap-2; + } + + .push-consent__message { + @apply mb-4; + } + + .password-field { + @apply relative; + } + + .password-field input { + @apply pr-12; + } + + .password-field__toggle { + @apply absolute right-2 top-1/2 grid h-9 w-9 -translate-y-1/2 place-items-center rounded-full; + color: var(--muted); + background-color: transparent; + } + + .password-field__toggle i { + @apply text-base; + } + + .password-field__toggle:hover { + background-color: color-mix(in srgb, var(--accent) 10%, transparent); + color: var(--text); + } + + .page, + .page-signals, + .admin-grid, + .signals-table, + .admin-list, + .auth-panel { + @apply grid gap-4; + } + + .admin-users-toolbar { + @apply my-4; + } + + .button-row { + @apply mt-4; + } + + .page-header, + .button-row, + .admin-row, + .admin-user-card__header, + .admin-user-card__body, + .push-row__heading, + .admin-users-toolbar, + .filters, + .signals-toolbar__row, + .signals-toolbar__tabs, + .signals-toolbar__per-page, + .signals-pagination__controls, + .toggle, + .admin-user-subscription, + .admin-bot-chip-list { + @apply flex flex-col gap-3; + } + + .page-header { + @apply rounded-[28px] border p-4; + border-color: var(--border); + background-color: var(--surface-strong); + } + + .page-header--admin-section { + @apply items-start; + } + + .page-header--signals h1 { + @apply my-1 text-3xl font-bold leading-tight; + } + + .eyebrow { + @apply m-0 text-xs font-semibold uppercase tracking-[0.18em]; + color: var(--muted); + } + + .muted, + .meta, + .signal-row__league, + .signal-row__status-hint, + .signal-row__time, + .signals-pagination__summary { + color: var(--muted); + } + + .signals-toolbar__search, + .admin-users-toolbar__search, + .signals-pagination__input { + @apply mt-0; + } + + .signals-toolbar__row--desktop { + @apply hidden; + } + + .signals-toolbar__row--mobile { + @apply flex; + } + + .signals-toolbar__compact-grid { + @apply grid gap-3; + grid-template-columns: minmax(0, 1fr); + } + + .signals-table { + @apply overflow-hidden rounded-[28px] border; + border-color: var(--border); + background-color: var(--surface-strong); + } + + .signal-row { + @apply grid gap-4 p-4; + border-bottom: 1px solid var(--border); + grid-template-columns: minmax(0, 1fr); + } + + .signal-row:last-child { + border-bottom: 0; + } + + .signal-row__teams, + .signal-row__status, + .signal-row__market, + .signal-row__bank, + .signal-row__team-list { + @apply grid gap-2; + } + + .signal-row__teams { + grid-template-columns: minmax(0, 1fr); + } + + .signal-row__time { + @apply flex items-center gap-2 text-sm; + } + + .signal-row__clock { + color: var(--accent); + } + + .signal-row__sport { + @apply inline-flex w-fit items-center gap-2 rounded-full border px-3 py-1.5 text-xs; + border-color: var(--border); + background-color: var(--surface); + } + + .signal-row__sport-name { + @apply max-w-[10rem] truncate; + } + + .signal-row__team-list { + @apply text-base; + } + + .signal-row__bot { + @apply text-xs font-semibold uppercase tracking-[0.14em]; + color: var(--accent); + } + + .signal-row__badge, + .status-pill { + @apply inline-flex w-fit min-h-10 mt-2 items-center rounded-full px-3 py-1 text-xs font-bold uppercase tracking-[0.08em]; + } + + .signal-row__badge--pending, + .status-pending, + .status-void, + .status-unpublished { + background-color: var(--surface-soft); + color: var(--text); + } + + .signal-row__badge--inactive { + background-color: var(--surface-soft); + color: var(--muted); + } + + .signal-row__badge--win, + .status-win { + background-color: var(--success-bg); + color: var(--success-text); + } + + .signal-row__badge--lose, + .status-lose { + background-color: var(--danger-bg); + color: var(--danger-text); + } + + .signal-row__badge--void, + .signal-row__badge--unpublished { + background-color: var(--surface-soft); + color: var(--text); + } + + .signal-row__badge--manual_review, + .status-manual_review { + background-color: var(--warning-bg); + color: var(--warning-text); + } + + .signal-row__odds { + @apply w-fit rounded-2xl px-4 py-2 text-lg font-extrabold; + background-color: var(--surface); + color: var(--text); + } + + .signal-main { + @apply text-base leading-7; + } + + .auth-page { + @apply grid min-h-[calc(100vh-12rem)] w-full items-center gap-8; + } + + .auth-panel { + @apply w-full max-w-md; + } + + .auth-page__intro { + @apply grid gap-3; + max-width: 32rem; + } + + .auth-page__intro h1 { + @apply m-0 text-4xl font-semibold leading-tight; + } + + .auth-page__intro .muted { + @apply m-0 text-base leading-7; + } + + .detail-grid, + .admin-summary-grid, + .admin-bot-access-grid, + .admin-subscription-grid { + @apply grid gap-4 mt-3; + grid-template-columns: minmax(0, 1fr); + } + + .detail-grid dt { + @apply mb-1 text-sm; + color: var(--muted); + } + + .detail-panel h1, + .panel h1, + .panel h2 { + @apply m-0; + } + + .info-box, + .error, + .success { + @apply rounded-2xl p-4 text-sm; + } + + .info-box { + background-color: color-mix(in srgb, var(--accent) 12%, var(--surface)); + } + + .error { + background-color: var(--danger-bg); + color: var(--danger-text); + } + + .success { + background-color: var(--success-bg); + color: var(--success-text); + } + + .toggle { + @apply items-center; + } + + .toggle input, + .admin-bot-access-option input { + @apply mt-0 h-4 w-4 shrink-0 rounded border; + } + + .admin-row, + .admin-user-card, + .push-row { + @apply border-t pt-4; + border-color: var(--border); + } + + .admin-summary-card { + @apply grid gap-2; + } + + .admin-summary-card strong { + @apply text-4xl leading-none; + } + + .admin-user-subscriptions { + @apply mt-3 grid gap-2; + } + + .admin-subscription-manager { + @apply grid gap-3; + } + + .admin-subscription-manager__toggle { + @apply flex list-none items-center justify-between gap-3 rounded-2xl border px-4 py-3 text-left; + border-color: var(--border); + background-color: color-mix(in srgb, var(--surface-strong) 72%, transparent); + cursor: pointer; + } + + .admin-subscription-manager__toggle-icon { + @apply inline-flex h-7 w-7 items-center justify-center rounded-full text-base font-semibold leading-none; + color: var(--muted); + background-color: color-mix(in srgb, var(--surface) 88%, transparent); + transition: transform 0.2s ease; + } + + .admin-subscription-manager__toggle::-webkit-details-marker { + display: none; + } + + .admin-subscription-manager__toggle::marker { + content: ""; + } + + .admin-subscription-manager[open] .admin-subscription-manager__toggle-icon { + transform: rotate(45deg); + } + + .admin-bot-access-option { + @apply flex items-start gap-3 rounded-2xl border p-4; + border-color: var(--border); + background-color: var(--surface); + } + + .admin-subscription-card { + @apply grid gap-3 rounded-2xl border p-4; + border-color: var(--border); + background-color: var(--surface); + } + + .admin-subscription-select { + border-width: 1px; + } + + .admin-subscription-select--active { + background-color: color-mix(in srgb, var(--success-bg) 45%, var(--surface)); + border-color: color-mix(in srgb, var(--success-text) 35%, var(--border)); + color: var(--success-text); + } + + .admin-subscription-select--expired { + background-color: color-mix(in srgb, var(--danger-bg) 30%, var(--surface)); + border-color: color-mix(in srgb, var(--danger-text) 28%, var(--border)); + color: var(--danger-text); + } + + .admin-subscription-select--canceled { + background-color: color-mix(in srgb, var(--surface-strong) 85%, var(--surface)); + border-color: var(--border); + color: var(--muted); + } + + .admin-subscription-table-wrap { + @apply hidden mt-3 overflow-x-auto rounded-2xl border; + border-color: var(--border); + background-color: var(--surface); + } + + .admin-subscription-table { + @apply w-full border-collapse; + } + + .admin-subscription-table th, + .admin-subscription-table td { + @apply p-4 align-top text-left; + border-bottom: 1px solid var(--border); + } + + .admin-subscription-table th { + @apply text-xs font-semibold uppercase tracking-[0.12em]; + color: var(--muted); + background: color-mix(in srgb, var(--surface-strong) 75%, transparent); + } + + .admin-subscription-table tbody tr:last-child td { + border-bottom: none; + } + + .admin-subscription-table__bot { + @apply min-w-[12rem]; + } + + .admin-subscription-table__bot strong, + .admin-subscription-table__bot small { + @apply block; + } + + .admin-subscription-table__bot small { + @apply mt-1; + color: var(--muted); + } + + .admin-subscription-table__field { + @apply grid gap-3 min-w-[12rem]; + } + + .admin-subscription-table td input, + .admin-subscription-table td select { + @apply w-full; + } + + .admin-subscription-table__actions { + @apply grid gap-2 min-w-[11rem]; + } + + .admin-subscription-card__header { + @apply flex items-start justify-between gap-3; + } + + .admin-subscription-card__header small { + @apply block mt-1; + color: var(--muted); + } + + .admin-bot-access-option span { + @apply grid gap-1; + } + + .admin-bot-access-option small { + color: var(--muted); + } + + .push-row { + @apply grid gap-4; + grid-template-columns: minmax(0, 1fr); + } + + .push-row__main, + .push-row__meta, + .push-row__event { + @apply grid gap-2; + } + + .push-row__endpoint { + @apply m-0 break-all text-sm; + color: var(--muted); + } + + .notification-settings { + @apply grid gap-5; + } + + .notification-settings__header { + @apply grid gap-2; + } + + .notification-settings__header h1 { + @apply m-0 text-2xl font-bold leading-tight; + } + + .notification-settings__header .muted { + @apply m-0 text-sm; + } + + .notification-settings__list { + @apply grid gap-3; + } + + .notification-option { + @apply flex items-start gap-3 rounded-2xl border p-4; + border-color: var(--border); + background-color: var(--surface); + } + + .notification-option input { + @apply mt-1 h-4 w-4 shrink-0 rounded border; + } + + .notification-option__body { + @apply grid gap-1; + } + + .notification-option__body strong { + @apply text-sm font-semibold; + } + + .notification-option__body small { + @apply text-sm leading-5; + color: var(--muted); + } + + .notification-settings__actions { + @apply grid gap-3; + } + + .settings-version { + @apply px-2 pb-2 text-center text-sm; + color: var(--muted); + } + + .mobile-nav__icon-wrap { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.75rem; + min-height: 1.5rem; + } + + .mobile-nav__badge { + position: absolute; + top: -0.55rem; + right: -0.85rem; + display: inline-flex; + min-width: 1.2rem; + height: 1.2rem; + align-items: center; + justify-content: center; + padding: 0 0.3rem; + border-radius: 999px; + background: #ef4444; + color: #fff; + font-size: 0.68rem; + font-weight: 800; + line-height: 1; + box-shadow: 0 6px 16px color-mix(in srgb, #ef4444 35%, transparent); + } + + .bots-page { + @apply gap-5; + } + + .bots-hero, + .bot-feed-hero, + .bot-feed-filters, + .bot-feed-pagination { + @apply rounded-[28px] border p-4; + border-color: var(--border); + background: + radial-gradient(circle at top right, color-mix(in srgb, var(--accent) 14%, transparent), transparent 32%), + var(--surface-strong); + } + + .bots-hero h1, + .bot-feed-hero h1 { + @apply m-0 text-3xl font-bold leading-tight; + } + + .bots-hero { + background: var(--surface-strong); + } + + .bots-hero__copy { + @apply grid gap-2; + max-width: 34rem; + } + + .bot-grid { + @apply grid gap-4; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .bot-tile { + @apply grid gap-4 rounded-3xl border p-3.5 transition; + min-height: 11.25rem; + align-content: space-between; + border-color: color-mix(in srgb, var(--border) 92%, white 8%); + background: var(--surface); + /* box-shadow: + 0 1px 2px color-mix(in srgb, var(--bg) 5%, transparent), + 0 10px 24px color-mix(in srgb, var(--bg) 6%, transparent); */ + } + + .bot-tile:hover { + border-color: color-mix(in srgb, var(--accent) 28%, var(--border)); + box-shadow: + 0 1px 2px color-mix(in srgb, var(--bg) 8%, transparent), + 0 14px 32px color-mix(in srgb, var(--bg) 10%, transparent); + transform: translateY(-2px); + } + + .bot-tile__top { + @apply flex items-center justify-between gap-3; + } + + .bot-tile__icon { + @apply grid h-10 w-10 place-items-center rounded-2xl; + border: 1px solid color-mix(in srgb, var(--border) 75%, transparent); + background: + linear-gradient(180deg, color-mix(in srgb, var(--accent) 10%, var(--surface)), var(--surface)); + color: var(--accent); + } + + .bot-tile__icon span { + @apply text-base font-semibold; + } + + .bot-tile__eyebrow { + @apply text-xs font-semibold uppercase tracking-[0.14em]; + margin: 0; + color: var(--muted); + } + + .bot-tile__count, + .bot-feed-hero__pill { + @apply inline-flex min-w-9 items-center justify-center rounded-full px-2.5 py-1 text-xs font-semibold; + border: 1px solid color-mix(in srgb, var(--accent) 18%, transparent); + background-color: color-mix(in srgb, var(--accent) 10%, var(--surface-strong)); + color: var(--accent-strong); + } + + + .bot-tile__body, + .bot-feed-hero__copy, + .bot-feed-hero__meta, + .bot-feed-filters__selects { + @apply grid gap-2; + } + + .bot-tile__body h2 { + @apply m-0 text-[1.35rem] font-semibold leading-tight; + } + + .bot-tile__meta { + @apply m-0 text-sm leading-5; + color: var(--muted); + } + + .bot-tile__footer { + @apply mt-1 flex flex-row items-center justify-between rounded-2xl border px-3 py-2; + border-color: color-mix(in srgb, var(--border) 82%, transparent); + background-color: color-mix(in srgb, var(--surface-strong) 80%, var(--surface)); + } + + .bot-tile__action { + @apply text-sm font-medium; + color: var(--text); + } + + .bot-tile__arrow { + @apply text-base leading-none; + color: var(--accent); + } + + .bot-feed-hero__back { + @apply inline-flex w-fit items-center rounded-full border px-3 py-1.5 text-sm font-medium transition; + border-color: var(--border); + color: var(--muted); + } + + .bot-feed-hero__back:hover { + color: var(--text); + background-color: var(--surface); + } + + .bot-feed-hero__meta { + @apply gap-2; + } + + .bot-feed-filters { + @apply grid gap-4; + } + + .bot-feed-filters__tabs { + @apply flex flex-wrap gap-2; + } + + .bot-feed-filters__selects { + grid-template-columns: minmax(0, 1fr); + } +} + +@layer utilities { + .surface-card { + background-color: var(--surface-strong); + border-color: var(--border); + } +} + +@media (min-width: 768px) { + @layer components { + .sakai-shell { + @apply grid; + grid-template-columns: 18rem minmax(0, 1fr); + } + + .sakai-shell--guest { + grid-template-columns: minmax(0, 1fr); + } + + .sakai-sidebar { + @apply sticky top-0 flex h-screen flex-col gap-6 border-r px-5 py-6; + border-color: var(--border); + background-color: color-mix(in srgb, var(--surface) 90%, white 10%); + } + + .sakai-bottom-bar { + @apply hidden; + } + + .sakai-sidebar__brand { + @apply flex items-center gap-3; + } + + .sakai-sidebar__brand strong { + @apply block text-lg; + } + + .sakai-sidebar__brand p { + @apply m-0 text-sm; + color: var(--muted); + } + + .sakai-sidebar__logo { + @apply grid h-12 w-12 place-items-center rounded-2xl text-xl; + background-color: color-mix(in srgb, var(--accent) 12%, var(--surface-soft)); + color: var(--accent-strong); + } + + .sakai-sidebar__nav { + @apply grid gap-2; + } + + .sakai-nav-item { + @apply inline-flex items-center gap-3 rounded-2xl px-3 py-3 text-sm font-medium transition; + color: var(--muted); + } + + .sakai-nav-item:hover, + .sakai-nav-item--active { + background-color: color-mix(in srgb, var(--accent) 10%, var(--surface-soft)); + color: var(--text); + } + + .sakai-sidebar__footer { + @apply mt-auto; + } + + .sakai-sidebar__hint { + @apply grid gap-1 rounded-2xl border p-4; + border-color: var(--border); + background-color: var(--surface); + } + + .sakai-sidebar__hint span { + @apply text-sm; + color: var(--muted); + } + + .sakai-topbar { + @apply flex flex-row items-center justify-between px-6 py-6; + } + + .sakai-topbar--guest { + @apply mx-auto w-full max-w-7xl items-start; + } + + .sakai-content { + @apply px-6 pb-8; + } + + .sakai-shell--guest .sakai-content { + @apply mx-auto w-full max-w-7xl px-6; + } + + .sakai-hero-card { + @apply flex flex-row items-start justify-between; + } + + .sakai-filter-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .sakai-signal-card { + @apply flex flex-row items-center justify-between; + } + + .sakai-signal-card--detail { + align-items: flex-start; + } + + .sakai-signal-card__side { + @apply grid justify-items-end; + min-width: 8rem; + } + + .sakai-copy-button--wide { + width: auto; + min-width: 11rem; + min-height: 2.75rem; + padding-inline: 1rem; + padding-block: 0.65rem; + } + + .sakai-signal-card__side > .sakai-signal-card__forecast { + width: auto; + max-width: 240px; + justify-self: stretch; + flex: initial; + } + + .sakai-signal-card__side--detail { + /* min-width: min(24rem, 38%); */ + } + + .sakai-signal-card__side--detail > .sakai-signal-card__forecast { + max-width: none; + width: 100%; + } + + .sakai-signal-layout--detail { + grid-template-columns: minmax(0, 1.45fr) minmax(20rem, 0.75fr); + gap: 1.5rem; + } + + .sakai-signal-detail-main { + min-width: 0; + } + + .sakai-signal-detail-aside { + align-self: start; + } + + .sakai-summary-panel--sticky { + position: sticky; + top: calc(1rem + env(safe-area-inset-top, 0px)); + } + + .auth-page { + @apply mx-auto; + grid-template-columns: minmax(0, 1.1fr) minmax(22rem, 28rem); + max-width: 72rem; + min-height: calc(100vh - 10rem); + } + + .app-shell--single { + @apply px-6 py-5; + } + + .topbar { + @apply flex flex-row items-start justify-between p-5; + } + + .topbar__actions, + .topbar__extras { + @apply items-end; + } + + .topbar__nav, + .push-consent__actions, + .filters, + .signals-toolbar__row, + .signals-toolbar__tabs, + .signals-toolbar__per-page, + .signals-pagination__controls, + .button-row, + .admin-users-toolbar, + .admin-bot-chip-list, + .push-row__heading { + @apply flex flex-row items-center; + } + + .user-chip { + @apply min-w-64; + } + + .page-header { + @apply items-start justify-between p-5; + } + + .page-header--signals h1 { + @apply text-5xl; + } + + .bots-hero h1, + .bot-feed-hero h1 { + @apply text-4xl; + } + + .signals-toolbar__search, + .admin-users-toolbar__search { + @apply flex-1; + } + + .signals-toolbar__row--desktop { + @apply hidden; + } + + .signals-toolbar__row--mobile { + @apply flex; + } + + .signal-row { + @apply items-center; + grid-template-columns: minmax(0, 1fr) auto; + } + + .signal-row__status { + @apply items-end text-right; + } + + .detail-grid, + .admin-summary-grid, + .admin-bot-access-grid, + .admin-subscription-grid { + + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .bot-feed-hero { + @apply flex flex-row items-end justify-between; + } + + .bot-feed-filters__selects { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .admin-user-card__header, + .admin-row { + @apply flex flex-row items-start justify-between; + } + + .admin-user-card__body { + @apply flex flex-col items-stretch; + } + } +} + +@media (min-width: 1024px) { + @layer components { + .sakai-bot-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .sakai-filter-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .sakai-signal-layout { + grid-template-columns: minmax(0, 2.1fr) minmax(280px, 0.9fr); + align-items: start; + } + + .topbar__actions { + @apply flex flex-row items-start justify-end; + } + + .topbar__extras { + @apply flex flex-row items-stretch; + } + + .signals-toolbar { + @apply gap-3; + } + + .signals-toolbar__row--desktop { + @apply flex; + } + + .signals-toolbar__row--mobile { + @apply hidden; + } + + .signals-toolbar__row { + @apply flex flex-row flex-wrap justify-end; + } + + .bot-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .bot-feed-filters { + grid-template-columns: minmax(240px, 1.2fr) minmax(220px, auto) minmax(320px, 1.4fr); + align-items: start; + } + + .bot-feed-filters__selects { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .signal-row { + grid-template-columns: minmax(0, 1fr) minmax(9rem, auto); + } + + .push-row { + grid-template-columns: minmax(280px, 2fr) minmax(220px, 1.2fr) minmax(240px, 1.4fr); + } + + .detail-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .admin-summary-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .admin-bot-access-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .admin-subscription-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .admin-subscription-grid--mobile { + @apply hidden; + } + + .admin-subscription-table-wrap { + @apply block; + } + } +} diff --git a/frontend/capacitor.config.ts b/frontend/capacitor.config.ts new file mode 100644 index 0000000..fafd390 --- /dev/null +++ b/frontend/capacitor.config.ts @@ -0,0 +1,22 @@ +import type { CapacitorConfig } from "@capacitor/cli"; + +const config: CapacitorConfig = { + appId: "com.alpinbet.app", + appName: "Антигол", + webDir: ".output/public", + bundledWebRuntime: false, + server: { + androidScheme: "https" + }, + plugins: { + SplashScreen: { + launchAutoHide: true, + launchShowDuration: 0 + }, + PushNotifications: { + presentationOptions: ["badge", "sound", "alert"] + } + } +}; + +export default config; diff --git a/frontend/components/AppLogo.vue b/frontend/components/AppLogo.vue new file mode 100644 index 0000000..5b25924 --- /dev/null +++ b/frontend/components/AppLogo.vue @@ -0,0 +1,26 @@ + + + diff --git a/frontend/components/SignalCard.vue b/frontend/components/SignalCard.vue new file mode 100644 index 0000000..404167c --- /dev/null +++ b/frontend/components/SignalCard.vue @@ -0,0 +1,153 @@ + + + diff --git a/frontend/composables/useApi.ts b/frontend/composables/useApi.ts new file mode 100644 index 0000000..98617d1 --- /dev/null +++ b/frontend/composables/useApi.ts @@ -0,0 +1,91 @@ +type ApiErrorPayload = { + message?: string; + issues?: { + formErrors?: string[]; + fieldErrors?: Record; + }; +}; + +type ApiLikeError = Error & { + statusCode?: number; + status?: number; + data?: ApiErrorPayload; +}; + +const apiFieldLabels: Record = { + email: "Email", + password: "Пароль", + confirmPassword: "Повтор пароля", + title: "Заголовок", + body: "Текст", + status: "Статус", + startsAt: "Начало", + expiresAt: "Окончание" +}; + +function getApiFieldLabel(field: string) { + return apiFieldLabels[field] ?? field; +} + +function getApiErrorMessage(error: ApiLikeError) { + const formErrors = Array.isArray(error.data?.issues?.formErrors) + ? error.data!.issues!.formErrors.filter((entry) => typeof entry === "string" && entry.trim()) + : []; + + if (formErrors.length > 0) { + return formErrors.join("\n"); + } + + const fieldErrors = error.data?.issues?.fieldErrors; + if (fieldErrors && typeof fieldErrors === "object") { + const messages = Object.entries(fieldErrors) + .flatMap(([field, value]) => + (Array.isArray(value) ? value : []) + .filter((entry) => typeof entry === "string" && entry.trim()) + .map((entry) => `${getApiFieldLabel(field)}: ${entry}`) + ); + + if (messages.length > 0) { + return messages.join("\n"); + } + } + + const responseMessage = typeof error.data?.message === "string" ? error.data.message.trim() : ""; + if (responseMessage) { + return responseMessage; + } + + const statusCode = error.statusCode ?? error.status; + + if (statusCode === 400) return "Проверьте правильность заполнения формы"; + if (statusCode === 401) return "Неверный логин или пароль"; + if (statusCode === 403) return "Недостаточно прав для выполнения действия"; + if (statusCode === 404) return "Запрашиваемые данные не найдены"; + if (statusCode === 409) return "Такая запись уже существует"; + if (statusCode === 422) return "Не удалось обработать введённые данные"; + if (statusCode && statusCode >= 500) return "Ошибка сервера. Попробуйте ещё раз позже"; + + return error.message || "Не удалось выполнить запрос"; +} + +export function useApi(path: string, options: Parameters>[1] = {}) { + const config = useRuntimeConfig(); + const token = useState("auth-token", () => null); + const baseUrl = process.server ? config.apiBaseInternal : config.public.apiBase; + + const headers = new Headers(options.headers as HeadersInit | undefined); + if (!headers.has("Content-Type") && options.method && options.method !== "GET") { + headers.set("Content-Type", "application/json"); + } + if (token.value) { + headers.set("Authorization", `Bearer ${token.value}`); + } + + return $fetch(`${baseUrl}${path}`, { + ...options, + headers, + credentials: "include" + }).catch((error: ApiLikeError) => { + throw new Error(getApiErrorMessage(error)); + }); +} diff --git a/frontend/composables/useAuth.ts b/frontend/composables/useAuth.ts new file mode 100644 index 0000000..2da8941 --- /dev/null +++ b/frontend/composables/useAuth.ts @@ -0,0 +1,92 @@ +import { Capacitor } from "@capacitor/core"; +import { Preferences } from "@capacitor/preferences"; +import type { User } from "~/types"; + +export function useAuth() { + const user = useState("auth-user", () => null); + const token = useState("auth-token", () => null); + const loading = useState("auth-loading", () => true); + + const isNativeApp = () => process.client && Capacitor.isNativePlatform(); + const webTokenStorageKey = "auth-token"; + + const persistToken = async (nextToken: string | null) => { + if (!process.client) { + return; + } + + if (isNativeApp()) { + if (nextToken) { + await Preferences.set({ key: webTokenStorageKey, value: nextToken }); + return; + } + + await Preferences.remove({ key: webTokenStorageKey }); + return; + } + + if (nextToken) { + window.localStorage.setItem(webTokenStorageKey, nextToken); + return; + } + + window.localStorage.removeItem(webTokenStorageKey); + }; + + const restoreToken = async () => { + if (!process.client || token.value) { + return; + } + + if (isNativeApp()) { + const storedToken = await Preferences.get({ key: webTokenStorageKey }); + token.value = storedToken.value; + return; + } + + token.value = window.localStorage.getItem(webTokenStorageKey); + }; + + const setSession = async (nextToken: string | null, nextUser: User | null) => { + token.value = nextToken; + user.value = nextUser; + await persistToken(nextToken); + }; + + const refreshMe = async () => { + await restoreToken(); + + try { + const me = await useApi("/auth/me"); + user.value = me; + } catch { + await setSession(null, null); + } finally { + loading.value = false; + } + }; + + const login = async (nextToken: string | null, nextUser: User) => { + loading.value = false; + await setSession(nextToken, nextUser); + }; + + const logout = async () => { + const { deactivateCurrentPushSubscription, syncServiceWorkerContext } = usePush(); + + await deactivateCurrentPushSubscription().catch(() => undefined); + await useApi("/auth/logout", { method: "POST" }).catch(() => undefined); + await setSession(null, null); + await syncServiceWorkerContext().catch(() => undefined); + await navigateTo("/login"); + }; + + return { + user, + token, + loading, + login, + logout, + refreshMe + }; +} diff --git a/frontend/composables/useBrowserDateTime.ts b/frontend/composables/useBrowserDateTime.ts new file mode 100644 index 0000000..98a87d3 --- /dev/null +++ b/frontend/composables/useBrowserDateTime.ts @@ -0,0 +1,34 @@ +type BrowserDateTimeOptions = Intl.DateTimeFormatOptions; + +const DEFAULT_DATE_TIME_OPTIONS: BrowserDateTimeOptions = { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit" +}; + +export function useBrowserDateTime() { + const browserTimeZone = useState("browser-time-zone", () => null); + + onMounted(() => { + browserTimeZone.value = Intl.DateTimeFormat().resolvedOptions().timeZone || null; + }); + + const formatDateTime = (value: string | Date | null | undefined, options: BrowserDateTimeOptions = DEFAULT_DATE_TIME_OPTIONS) => { + if (!value) return "—"; + + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) return "—"; + + return new Intl.DateTimeFormat("ru-RU", { + ...options, + timeZone: browserTimeZone.value ?? "UTC" + }).format(date); + }; + + return { + browserTimeZone, + formatDateTime + }; +} diff --git a/frontend/composables/useChatApi.ts b/frontend/composables/useChatApi.ts new file mode 100644 index 0000000..16fa338 --- /dev/null +++ b/frontend/composables/useChatApi.ts @@ -0,0 +1,25 @@ +export function useChatApi(path: string, options: Parameters>[1] = {}) { + const config = useRuntimeConfig(); + const { token, user } = useAuth(); + const headers = new Headers(options.headers as HeadersInit | undefined); + + if (!headers.has("Content-Type") && options.method && options.method !== "GET") { + headers.set("Content-Type", "application/json"); + } + + if (token.value) { + headers.set("Authorization", `Bearer ${token.value}`); + } + + if (user.value?.email) { + headers.set("x-user-email", user.value.email); + } + + return $fetch(`${config.public.chatApiBase}${path}`, { + ...options, + headers, + credentials: "include" + }).catch((error: Error) => { + throw new Error(error.message || "Не удалось выполнить запрос к чату"); + }); +} diff --git a/frontend/composables/useClipboard.ts b/frontend/composables/useClipboard.ts new file mode 100644 index 0000000..6301ee9 --- /dev/null +++ b/frontend/composables/useClipboard.ts @@ -0,0 +1,45 @@ +export const useClipboard = () => { + const fallbackCopy = (value: string) => { + const textarea = document.createElement("textarea"); + textarea.value = value; + textarea.setAttribute("readonly", "true"); + textarea.style.position = "fixed"; + textarea.style.top = "-9999px"; + textarea.style.left = "-9999px"; + textarea.style.opacity = "0"; + + document.body.appendChild(textarea); + textarea.focus(); + textarea.select(); + + try { + return document.execCommand("copy"); + } finally { + document.body.removeChild(textarea); + } + }; + + const copyText = async (value: string) => { + if (!process.client) { + return false; + } + + const normalized = value.trim(); + if (!normalized) { + return false; + } + + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(normalized); + return true; + } + } catch { + // Fall through to legacy copy API for embedded webviews. + } + + return fallbackCopy(normalized); + }; + + return { copyText }; +}; diff --git a/frontend/composables/usePush.ts b/frontend/composables/usePush.ts new file mode 100644 index 0000000..8e4bb08 --- /dev/null +++ b/frontend/composables/usePush.ts @@ -0,0 +1,784 @@ +import { App as CapacitorApp } from "@capacitor/app"; +import { Capacitor } from "@capacitor/core"; +import { Device } from "@capacitor/device"; +import { Preferences } from "@capacitor/preferences"; +import { PushNotifications, type Token } from "@capacitor/push-notifications"; + +type PushSubscriptionsResponse = { + items: Array<{ + id: string; + active: boolean; + createdAt: string; + updatedAt: string; + endpoint?: string; + platform?: string; + }>; + hasActiveSubscription: boolean; +}; + +type BeforeInstallPromptEvent = Event & { + prompt: () => Promise; + userChoice: Promise<{ outcome: "accepted" | "dismissed"; platform: string }>; +}; + +type NotificationPermissionState = "default" | "denied" | "granted" | "prompt" | "unsupported"; + +const ANON_PUSH_CLIENT_ID_KEY = "anonymous-push-client-id"; +const PENDING_PUSH_ROUTE_KEY = "pending-push-route"; + +export function usePush() { + const { user, token } = useAuth(); + const config = useRuntimeConfig(); + const installPromptEvent = useState("pwa-install-prompt-event", () => null); + const anonymousClientId = useState("anonymous-push-client-id", () => null); + const nativePushToken = useState("native-push-token", () => null); + const nativePushDeviceId = useState("native-push-device-id", () => null); + const nativePushInitialized = useState("native-push-initialized", () => false); + const webPushClickInitialized = useState("web-push-click-initialized", () => false); + const pendingPushRoute = useState("pending-push-route", () => null); + + const isNativeApp = () => process.client && Capacitor.isNativePlatform(); + + const getNativePlatform = () => { + if (!isNativeApp()) { + return null; + } + + const platform = Capacitor.getPlatform(); + if (platform === "android" || platform === "ios") { + return platform; + } + + return null; + }; + + const readStoredAnonymousClientId = async () => { + if (!process.client) { + return null; + } + + if (isNativeApp()) { + const result = await Preferences.get({ key: ANON_PUSH_CLIENT_ID_KEY }); + return result.value; + } + + return localStorage.getItem(ANON_PUSH_CLIENT_ID_KEY); + }; + + const persistAnonymousClientId = async (value: string) => { + if (!process.client) { + return; + } + + if (isNativeApp()) { + await Preferences.set({ key: ANON_PUSH_CLIENT_ID_KEY, value }); + return; + } + + localStorage.setItem(ANON_PUSH_CLIENT_ID_KEY, value); + }; + + const clearAnonymousClientId = async () => { + if (!process.client) { + return; + } + + anonymousClientId.value = null; + + if (isNativeApp()) { + await Preferences.remove({ key: ANON_PUSH_CLIENT_ID_KEY }); + return; + } + + localStorage.removeItem(ANON_PUSH_CLIENT_ID_KEY); + }; + + const readStoredPendingPushRoute = async () => { + if (!process.client) { + return null; + } + + if (isNativeApp()) { + const result = await Preferences.get({ key: PENDING_PUSH_ROUTE_KEY }); + return result.value; + } + + return localStorage.getItem(PENDING_PUSH_ROUTE_KEY); + }; + + const persistPendingPushRoute = async (value: string | null) => { + if (!process.client) { + return; + } + + pendingPushRoute.value = value; + + if (isNativeApp()) { + if (value) { + await Preferences.set({ key: PENDING_PUSH_ROUTE_KEY, value }); + } else { + await Preferences.remove({ key: PENDING_PUSH_ROUTE_KEY }); + } + return; + } + + if (value) { + localStorage.setItem(PENDING_PUSH_ROUTE_KEY, value); + } else { + localStorage.removeItem(PENDING_PUSH_ROUTE_KEY); + } + }; + + const normalizeTargetRoute = (targetUrl: unknown) => { + if (typeof targetUrl !== "string" || targetUrl.length === 0) { + return null; + } + + if (targetUrl.startsWith("/")) { + return targetUrl; + } + + try { + const parsed = new URL(targetUrl); + return `${parsed.pathname}${parsed.search}${parsed.hash}` || "/"; + } catch { + return null; + } + }; + + const openPushTarget = async (targetUrl: unknown) => { + const route = normalizeTargetRoute(targetUrl); + if (!route) { + return; + } + + await persistPendingPushRoute(route); + + if (user.value) { + await navigateTo(route); + await persistPendingPushRoute(null); + return; + } + + await navigateTo(`/login?redirect=${encodeURIComponent(route)}`); + }; + + const consumePendingPushRoute = async () => { + if (!process.client) { + return null; + } + + if (!pendingPushRoute.value) { + pendingPushRoute.value = await readStoredPendingPushRoute(); + } + + const route = pendingPushRoute.value; + if (!route || !user.value) { + return null; + } + + await navigateTo(route); + await persistPendingPushRoute(null); + return route; + }; + + const ensureAnonymousClientId = async () => { + if (!process.client) { + return "server-client"; + } + + if (!anonymousClientId.value) { + anonymousClientId.value = await readStoredAnonymousClientId(); + } + + if (!anonymousClientId.value) { + anonymousClientId.value = crypto.randomUUID().replace(/[^a-zA-Z0-9_-]/g, ""); + await persistAnonymousClientId(anonymousClientId.value); + } + + return anonymousClientId.value; + }; + + const registerServiceWorker = async () => { + if (!process.client || isNativeApp() || !("serviceWorker" in navigator)) { + return null; + } + + return navigator.serviceWorker.register("/sw.js"); + }; + + const syncServiceWorkerContext = async () => { + if (!process.client || isNativeApp() || !("serviceWorker" in navigator)) { + return; + } + + const registration = await registerServiceWorker(); + const readyRegistration = registration ?? (await navigator.serviceWorker.ready); + const worker = readyRegistration.active ?? navigator.serviceWorker.controller; + if (!worker) { + return; + } + + worker.postMessage({ + type: "push-context-sync", + payload: { + apiBase: config.public.apiBase, + isAuthenticated: Boolean(user.value), + anonymousClientId: await ensureAnonymousClientId() + } + }); + }; + + const urlBase64ToUint8Array = (base64String: string) => { + const normalizedInput = base64String.trim(); + if (!normalizedInput || normalizedInput.startsWith("replace_")) { + throw new Error("Push-уведомления еще не настроены на сервере"); + } + + const padding = "=".repeat((4 - (base64String.length % 4)) % 4); + const base64 = (normalizedInput + padding).replace(/-/g, "+").replace(/_/g, "/"); + + try { + const rawData = window.atob(base64); + return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0))); + } catch { + throw new Error("На сервере задан некорректный VAPID public key"); + } + }; + + const isMobileDevice = () => { + if (!process.client) { + return false; + } + + if (isNativeApp()) { + return true; + } + + return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); + }; + + const isIosDevice = () => { + if (!process.client) { + return false; + } + + if (isNativeApp()) { + return Capacitor.getPlatform() === "ios"; + } + + return /iPhone|iPad|iPod/i.test(navigator.userAgent); + }; + + const isStandaloneMode = () => { + if (!process.client) { + return false; + } + + if (isNativeApp()) { + return true; + } + + const standaloneMedia = window.matchMedia?.("(display-mode: standalone)")?.matches ?? false; + const standaloneNavigator = + "standalone" in navigator ? Boolean((navigator as Navigator & { standalone?: boolean }).standalone) : false; + return standaloneMedia || standaloneNavigator; + }; + + const setInstallPromptEvent = (event: BeforeInstallPromptEvent | null) => { + installPromptEvent.value = event; + }; + + const canTriggerInstallPrompt = () => !isNativeApp() && Boolean(installPromptEvent.value); + + const triggerInstallPrompt = async () => { + if (!installPromptEvent.value) { + return false; + } + + await installPromptEvent.value.prompt(); + const choice = await installPromptEvent.value.userChoice; + installPromptEvent.value = null; + return choice.outcome === "accepted"; + }; + + const getBrowserPermissionState = (): NotificationPermissionState => { + if (!process.client || !("Notification" in window)) { + return "unsupported"; + } + + return Notification.permission; + }; + + const getNativePermissionState = async (): Promise => { + if (!isNativeApp()) { + return "unsupported"; + } + + const permissions = await PushNotifications.checkPermissions(); + if (permissions.receive === "prompt") { + return "default"; + } + + return permissions.receive; + }; + + const getPermissionState = () => { + if (isNativeApp()) { + return getNativePermissionState(); + } + + return Promise.resolve(getBrowserPermissionState()); + }; + + const requestBrowserPermission = async (): Promise => { + if (!process.client || !("Notification" in window)) { + return "unsupported"; + } + + return Notification.requestPermission(); + }; + + const requestNativePermission = async (): Promise => { + if (!isNativeApp()) { + return "unsupported"; + } + + const permissions = await PushNotifications.requestPermissions(); + if (permissions.receive === "prompt") { + return "default"; + } + + return permissions.receive; + }; + + const requestPermission = () => { + if (isNativeApp()) { + return requestNativePermission(); + } + + return requestBrowserPermission(); + }; + + const syncWebSubscription = async (subscription: PushSubscription) => { + const serializedSubscription = subscription.toJSON(); + if (!serializedSubscription.endpoint || !serializedSubscription.keys?.p256dh || !serializedSubscription.keys?.auth) { + throw new Error("Браузер вернул неполную push-подписку"); + } + + if (user.value) { + await useApi("/me/push-subscriptions", { + method: "POST", + body: serializedSubscription + }); + return; + } + + await useApi("/public/push-subscriptions", { + method: "POST", + body: { + clientId: await ensureAnonymousClientId(), + ...serializedSubscription + } + }); + }; + + const syncNativeSubscription = async (tokenValue = nativePushToken.value) => { + const platform = getNativePlatform(); + if (!platform || !tokenValue) { + return; + } + + if (!nativePushDeviceId.value) { + const device = await Device.getId(); + nativePushDeviceId.value = device.identifier; + } + + const body = { + token: tokenValue, + platform, + deviceId: nativePushDeviceId.value ?? undefined + }; + + if (user.value) { + await useApi("/me/native-push-subscriptions", { + method: "POST", + body + }); + return; + } + + await useApi("/public/native-push-subscriptions", { + method: "POST", + body: { + clientId: await ensureAnonymousClientId(), + ...body + } + }); + }; + + const initializeNativePush = async () => { + if (!isNativeApp() || nativePushInitialized.value) { + return; + } + + nativePushInitialized.value = true; + + await PushNotifications.createChannel({ + id: "signals", + name: "Signals", + description: "Уведомления о новых сигналах", + importance: 5, + visibility: 1, + sound: "default" + }).catch(() => undefined); + + await PushNotifications.addListener("registration", async (registrationToken: Token) => { + nativePushToken.value = registrationToken.value; + await syncNativeSubscription(registrationToken.value); + }); + + await PushNotifications.addListener("registrationError", (error) => { + console.error("Native push registration failed", error); + }); + + await PushNotifications.addListener("pushNotificationActionPerformed", (notification) => { + const targetUrl = + notification.notification.data?.url || + notification.notification.data?.link || + notification.notification.data?.path; + + void openPushTarget(targetUrl); + }); + + await CapacitorApp.addListener("appUrlOpen", ({ url }) => { + if (!url) { + return; + } + + try { + const parsed = new URL(url); + const route = `${parsed.pathname}${parsed.search}${parsed.hash}`; + void openPushTarget(route || "/"); + } catch { + // Ignore invalid deep links. + } + }); + }; + + const initializeWebPushRouting = () => { + if (!process.client || isNativeApp() || webPushClickInitialized.value || !("serviceWorker" in navigator)) { + return; + } + + webPushClickInitialized.value = true; + + navigator.serviceWorker.addEventListener("message", (event) => { + if (event.data?.type !== "push-notification-click") { + return; + } + + void openPushTarget(event.data.url); + }); + }; + + const getCurrentBrowserSubscription = async () => { + const registration = await registerServiceWorker(); + if (!registration || !("PushManager" in window)) { + return null; + } + + return registration.pushManager.getSubscription(); + }; + + const claimAnonymousPushSubscriptions = async () => { + if (!process.client || !user.value) { + return; + } + + const existingClientId = anonymousClientId.value ?? (await readStoredAnonymousClientId()); + if (!existingClientId) { + await syncServiceWorkerContext(); + return; + } + + anonymousClientId.value = existingClientId; + + if (isNativeApp()) { + if (nativePushToken.value) { + await syncNativeSubscription(nativePushToken.value); + } + + const anonymousSubscriptions = await useApi( + `/public/native-push-subscriptions/${existingClientId}` + ).catch(() => null); + + for (const subscription of anonymousSubscriptions?.items ?? []) { + await useApi(`/public/native-push-subscriptions/${existingClientId}/${subscription.id}`, { + method: "DELETE" + }).catch(() => undefined); + } + } else { + const currentSubscription = await getCurrentBrowserSubscription(); + if (currentSubscription) { + await syncWebSubscription(currentSubscription); + } + + const anonymousSubscriptions = await useApi( + `/public/push-subscriptions/${existingClientId}` + ).catch(() => null); + + for (const subscription of anonymousSubscriptions?.items ?? []) { + await useApi(`/public/push-subscriptions/${existingClientId}/${subscription.id}`, { + method: "DELETE" + }).catch(() => undefined); + } + } + + await clearAnonymousClientId(); + await syncServiceWorkerContext(); + }; + + const getCurrentSubscription = async () => { + if (isNativeApp()) { + return nativePushToken.value; + } + + return getCurrentBrowserSubscription(); + }; + + const deactivateCurrentPushSubscription = async () => { + if (!process.client || !user.value) { + return; + } + + if (isNativeApp()) { + if (!nativePushToken.value) { + return; + } + + await useApi("/me/native-push-subscriptions/deactivate", { + method: "POST", + body: { + token: nativePushToken.value + } + }); + + return; + } + + const currentSubscription = await getCurrentBrowserSubscription(); + if (!currentSubscription) { + return; + } + + const serializedSubscription = currentSubscription.toJSON(); + if (!serializedSubscription.endpoint) { + return; + } + + await useApi("/me/push-subscriptions/deactivate", { + method: "POST", + body: { + endpoint: serializedSubscription.endpoint + } + }); + + await currentSubscription.unsubscribe().catch(() => undefined); + }; + + const subscribeToBrowserPush = async () => { + const registration = await registerServiceWorker(); + if (!registration || !("PushManager" in window)) { + throw new Error("Push не поддерживается в этом браузере"); + } + + const vapid = await useApi<{ publicKey: string }>("/vapid-public-key"); + if (!vapid.publicKey || vapid.publicKey.startsWith("replace_")) { + throw new Error("Push-уведомления еще не настроены на сервере"); + } + + const subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(vapid.publicKey) + }); + + await syncWebSubscription(subscription); + return subscription; + }; + + const subscribeToNativePush = async () => { + await initializeNativePush(); + + let permission = await getNativePermissionState(); + if (permission === "default") { + permission = await requestNativePermission(); + } + + if (permission !== "granted") { + throw new Error("Разрешение на уведомления не выдано"); + } + + const device = await Device.getId(); + nativePushDeviceId.value = device.identifier; + + return new Promise((resolve, reject) => { + let finished = false; + + const finalize = (callback: () => void) => { + if (finished) { + return; + } + + finished = true; + callback(); + }; + + PushNotifications.addListener("registration", async (registrationToken: Token) => { + finalize(() => { + nativePushToken.value = registrationToken.value; + void syncNativeSubscription(registrationToken.value); + resolve(registrationToken.value); + }); + }).catch(reject); + + PushNotifications.addListener("registrationError", (error) => { + finalize(() => { + reject(new Error(error.error || "Не удалось зарегистрировать устройство для push")); + }); + }).catch(reject); + + PushNotifications.register().catch((error) => { + finalize(() => { + reject(error instanceof Error ? error : new Error("Не удалось зарегистрировать устройство для push")); + }); + }); + }); + }; + + const subscribeToPush = () => { + if (isNativeApp()) { + return subscribeToNativePush(); + } + + return subscribeToBrowserPush(); + }; + + const ensurePushSubscription = async () => { + const permission = await getPermissionState(); + if (permission === "unsupported") { + throw new Error("Push не поддерживается на этом устройстве"); + } + + let finalPermission = permission; + if (finalPermission === "default") { + finalPermission = await requestPermission(); + } + + if (finalPermission !== "granted") { + throw new Error("Разрешение на уведомления не выдано"); + } + + if (isNativeApp()) { + if (nativePushToken.value) { + await syncNativeSubscription(nativePushToken.value); + return nativePushToken.value; + } + + return subscribeToNativePush(); + } + + const currentSubscription = await getCurrentBrowserSubscription(); + if (currentSubscription) { + await syncWebSubscription(currentSubscription); + return currentSubscription; + } + + return subscribeToBrowserPush(); + }; + + const getPushStatus = async () => { + const permission = await getPermissionState(); + const isMobile = isMobileDevice(); + const isIos = isIosDevice(); + const isStandalone = isStandaloneMode(); + const installRequired = !isNativeApp() && isMobile && !isStandalone; + + if (permission === "unsupported") { + return { + supported: false, + permission, + isMobile, + isIos, + isStandalone, + installRequired, + canInstall: canTriggerInstallPrompt(), + hasBrowserSubscription: false, + hasServerSubscription: false + }; + } + + let hasServerSubscription = false; + + try { + if (isNativeApp()) { + if (user.value) { + const serverSubscriptions = await useApi("/me/native-push-subscriptions"); + hasServerSubscription = serverSubscriptions.hasActiveSubscription; + } else { + const clientId = await ensureAnonymousClientId(); + const serverSubscriptions = await useApi(`/public/native-push-subscriptions/${clientId}`); + hasServerSubscription = serverSubscriptions.hasActiveSubscription; + } + } else if (user.value) { + const serverSubscriptions = await useApi("/me/push-subscriptions"); + hasServerSubscription = serverSubscriptions.hasActiveSubscription; + } else { + const clientId = await ensureAnonymousClientId(); + const serverSubscriptions = await useApi(`/public/push-subscriptions/${clientId}`); + hasServerSubscription = serverSubscriptions.hasActiveSubscription; + } + } catch { + hasServerSubscription = false; + } + + return { + supported: true, + permission, + isMobile, + isIos, + isStandalone, + installRequired, + canInstall: canTriggerInstallPrompt(), + hasBrowserSubscription: isNativeApp() + ? Boolean(nativePushToken.value) + : Boolean(await getCurrentBrowserSubscription()), + hasServerSubscription + }; + }; + + return { + registerServiceWorker, + syncServiceWorkerContext, + syncNativeSubscription, + initializeNativePush, + initializeWebPushRouting, + isNativeApp, + isMobileDevice, + isIosDevice, + isStandaloneMode, + getPermissionState, + requestPermission, + getCurrentSubscription, + deactivateCurrentPushSubscription, + getPushStatus, + subscribeToPush, + ensurePushSubscription, + claimAnonymousPushSubscriptions, + consumePendingPushRoute, + setInstallPromptEvent, + canTriggerInstallPrompt, + triggerInstallPrompt, + ensureAnonymousClientId + }; +} diff --git a/frontend/composables/useSupportRealtime.ts b/frontend/composables/useSupportRealtime.ts new file mode 100644 index 0000000..d4d4e47 --- /dev/null +++ b/frontend/composables/useSupportRealtime.ts @@ -0,0 +1,70 @@ +import { io, type Socket } from "socket.io-client"; +import type { SupportConversation, SupportMessage } from "~/types"; + +type SupportRealtimePayload = { + conversation: SupportConversation; + message?: SupportMessage; +}; + +export function useSupportRealtime() { + const socket = useState("support-realtime-socket", () => null); + const { token } = useAuth(); + const config = useRuntimeConfig(); + + const connect = () => { + if (!process.client || !token.value) { + return null; + } + + if (socket.value?.connected) { + return socket.value; + } + + if (socket.value) { + socket.value.auth = { token: token.value }; + socket.value.connect(); + return socket.value; + } + + const nextSocket = io(config.public.chatApiBase, { + transports: ["websocket", "polling"], + withCredentials: true, + auth: { + token: token.value + } + }); + + socket.value = nextSocket; + return nextSocket; + }; + + const disconnect = () => { + socket.value?.disconnect(); + }; + + const onConversationUpdated = (handler: (payload: SupportRealtimePayload) => void) => { + const activeSocket = connect(); + activeSocket?.on("support:conversation.updated", handler); + + return () => { + activeSocket?.off("support:conversation.updated", handler); + }; + }; + + const onMessageCreated = (handler: (payload: SupportRealtimePayload) => void) => { + const activeSocket = connect(); + activeSocket?.on("support:message.created", handler); + + return () => { + activeSocket?.off("support:message.created", handler); + }; + }; + + return { + socket, + connect, + disconnect, + onConversationUpdated, + onMessageCreated + }; +} diff --git a/frontend/composables/useSupportUnread.ts b/frontend/composables/useSupportUnread.ts new file mode 100644 index 0000000..665f43a --- /dev/null +++ b/frontend/composables/useSupportUnread.ts @@ -0,0 +1,106 @@ +import type { SupportConversation } from "~/types"; + +const toConversationMap = (items: SupportConversation[]) => + Object.fromEntries(items.map((item) => [item.id, item])) as Record; + +export function useSupportUnread() { + const { user, token } = useAuth(); + const { connect, onConversationUpdated, onMessageCreated } = useSupportRealtime(); + + const conversations = useState>("support-unread-conversations", () => ({})); + const unreadCount = useState("support-unread-count", () => 0); + const initialized = useState("support-unread-initialized", () => false); + const loading = useState("support-unread-loading", () => false); + const offConversationUpdated = useState<(() => void) | null>("support-unread-off-conversation", () => null); + const offMessageCreated = useState<(() => void) | null>("support-unread-off-message", () => null); + + const recomputeUnreadCount = () => { + const items = Object.values(conversations.value); + + unreadCount.value = user.value?.role === "admin" + ? items.filter((item) => item.unreadForAdmin).length + : items.some((item) => item.unreadForUser) ? 1 : 0; + }; + + const replaceConversations = (items: SupportConversation[]) => { + conversations.value = toConversationMap(items); + recomputeUnreadCount(); + }; + + const upsertConversation = (item: SupportConversation) => { + conversations.value = { + ...conversations.value, + [item.id]: item + }; + recomputeUnreadCount(); + }; + + const clearState = () => { + conversations.value = {}; + unreadCount.value = 0; + initialized.value = false; + offConversationUpdated.value?.(); + offConversationUpdated.value = null; + offMessageCreated.value?.(); + offMessageCreated.value = null; + }; + + const refreshUnread = async () => { + if (!user.value || !token.value || loading.value) { + return; + } + + loading.value = true; + + try { + if (user.value.role === "admin") { + const response = await useChatApi("/admin/support/conversations"); + replaceConversations(response); + } else { + const response = await useChatApi("/support/conversation"); + replaceConversations([response]); + } + } catch { + // Ignore unread refresh failures in shell navigation. + } finally { + loading.value = false; + } + }; + + const ensureRealtime = () => { + if (!process.client || !token.value || offConversationUpdated.value || offMessageCreated.value) { + return; + } + + connect(); + offConversationUpdated.value = onConversationUpdated(({ conversation }) => { + if (conversation) { + upsertConversation(conversation); + } + }); + offMessageCreated.value = onMessageCreated(({ conversation }) => { + if (conversation) { + upsertConversation(conversation); + } + }); + }; + + const initializeUnread = async () => { + if (initialized.value || !user.value || !token.value) { + return; + } + + await refreshUnread(); + ensureRealtime(); + initialized.value = true; + }; + + return { + unreadCount, + initializeUnread, + refreshUnread, + replaceConversations, + upsertConversation, + clearState + }; +} diff --git a/frontend/composables/useTheme.ts b/frontend/composables/useTheme.ts new file mode 100644 index 0000000..506baf4 --- /dev/null +++ b/frontend/composables/useTheme.ts @@ -0,0 +1,66 @@ +type ThemeMode = "light" | "dark"; + +const STORAGE_KEY = "signals-theme"; + +const themes: Record = { + light: { + background: "#f3f6fb", + surface: "#ffffff" + }, + dark: { + background: "#0f172a", + surface: "#162033" + } +}; + +export const useTheme = () => { + const theme = useState("theme-mode", () => "light"); + const initialized = useState("theme-initialized", () => false); + + const applyTheme = (nextTheme: ThemeMode) => { + if (!process.client) { + return; + } + + theme.value = nextTheme; + + const root = document.documentElement; + root.dataset.theme = nextTheme; + root.style.colorScheme = nextTheme; + root.classList.toggle("app-dark", nextTheme === "dark"); + + localStorage.setItem(STORAGE_KEY, nextTheme); + + const themeMeta = document.querySelector('meta[name="theme-color"]'); + const themeColor = themes[nextTheme].surface; + + if (themeMeta) { + themeMeta.setAttribute("content", themeColor); + } + + document.body.style.backgroundColor = themes[nextTheme].background; + }; + + const initializeTheme = () => { + if (!process.client || initialized.value) { + return; + } + + const storedTheme = localStorage.getItem(STORAGE_KEY); + const nextTheme = storedTheme === "light" || storedTheme === "dark" ? storedTheme : "light"; + + applyTheme(nextTheme); + initialized.value = true; + }; + + const toggleTheme = () => { + applyTheme(theme.value === "dark" ? "light" : "dark"); + }; + + return { + theme: readonly(theme), + initializeTheme, + applyTheme, + toggleTheme + }; +}; diff --git a/frontend/layouts/default.vue b/frontend/layouts/default.vue new file mode 100644 index 0000000..7d66b27 --- /dev/null +++ b/frontend/layouts/default.vue @@ -0,0 +1,221 @@ + + + diff --git a/frontend/middleware/admin.ts b/frontend/middleware/admin.ts new file mode 100644 index 0000000..82bf2af --- /dev/null +++ b/frontend/middleware/admin.ts @@ -0,0 +1,19 @@ +export default defineNuxtRouteMiddleware(async () => { + if (process.server) { + return; + } + + const { user, loading, refreshMe } = useAuth(); + + if (loading.value) { + await refreshMe(); + } + + if (!user.value) { + return navigateTo("/login"); + } + + if (user.value.role !== "admin") { + return navigateTo("/"); + } +}); diff --git a/frontend/middleware/auth.ts b/frontend/middleware/auth.ts new file mode 100644 index 0000000..f49b358 --- /dev/null +++ b/frontend/middleware/auth.ts @@ -0,0 +1,16 @@ +export default defineNuxtRouteMiddleware(async () => { + if (process.server) { + return; + } + + const route = useRoute(); + const { user, loading, refreshMe } = useAuth(); + + if (loading.value) { + await refreshMe(); + } + + if (!user.value) { + return navigateTo(`/login?redirect=${encodeURIComponent(route.fullPath || "/")}`); + } +}); diff --git a/frontend/nuxt.config.ts b/frontend/nuxt.config.ts new file mode 100644 index 0000000..3947bb0 --- /dev/null +++ b/frontend/nuxt.config.ts @@ -0,0 +1,48 @@ +import dotenv from "dotenv"; +import { resolve } from "node:path"; +import tailwindcss from "@tailwindcss/vite"; + +dotenv.config({ path: resolve(__dirname, ".env") }); +dotenv.config({ path: resolve(__dirname, "../.env"), override: false }); + +const isDev = process.env.NODE_ENV !== "production"; +const defaultPublicApiBase = isDev ? "http://localhost:4000" : "https://api.antigol.ru"; + +export default defineNuxtConfig({ + compatibilityDate: "2026-03-18", + ssr: false, + devtools: { enabled: true }, + css: ["primeicons/primeicons.css", "~/assets/css/main.css"], + vite: { + plugins: [tailwindcss()] + }, + runtimeConfig: { + apiBaseInternal: process.env.NUXT_API_BASE_INTERNAL || "http://backend:4000", + public: { + apiBase: process.env.NUXT_PUBLIC_API_BASE || defaultPublicApiBase, + chatApiBase: process.env.NUXT_PUBLIC_CHAT_API_BASE || (isDev ? "http://localhost:4050" : "https://chat.antigol.ru") + } + }, + app: { + head: { + htmlAttrs: { + lang: "ru" + }, + title: "Антигол", + meta: [ + { name: "viewport", content: "width=device-width, initial-scale=1" }, + { name: "theme-color", content: "#0f172a" }, + { name: "mobile-web-app-capable", content: "yes" }, + { name: "apple-mobile-web-app-capable", content: "yes" }, + { name: "apple-mobile-web-app-status-bar-style", content: "black-translucent" }, + { name: "apple-mobile-web-app-title", content: "Антигол" } + ], + link: [ + { rel: "manifest", href: "/manifest.webmanifest" }, + { rel: "icon", href: "/icons/favicon-32.png", type: "image/png", sizes: "32x32" }, + { rel: "icon", href: "/icons/favicon-16.png", type: "image/png", sizes: "16x16" }, + { rel: "apple-touch-icon", href: "/icons/apple-touch-icon.png", sizes: "180x180" } + ] + } + } +}); diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..2b8c520 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,38 @@ +{ + "name": "betting-signals-frontend", + "version": "1.0.5", + "private": true, + "type": "module", + "scripts": { + "dev": "nuxt dev", + "build": "nuxt build", + "generate": "nuxi generate", + "preview": "nuxt preview", + "cap:sync": "npx cap sync", + "cap:open:android": "npx cap open android", + "cap:open:ios": "npx cap open ios", + "build:mobile": "npm run generate && npm run cap:sync" + }, + "dependencies": { + "@capacitor/android": "^7.4.3", + "@capacitor/app": "^7.0.2", + "@capacitor/core": "^7.4.3", + "@capacitor/device": "^7.0.2", + "@capacitor/ios": "^7.4.3", + "@capacitor/preferences": "^7.0.2", + "@capacitor/push-notifications": "^7.0.2", + "@capacitor/splash-screen": "^7.0.5", + "@primeuix/themes": "^2.0.3", + "nuxt": "4.4.2", + "primeicons": "^7.0.0", + "primevue": "^4.5.4", + "socket.io-client": "^4.8.3", + "vue": "^3.5.13", + "vue-router": "^4.5.1" + }, + "devDependencies": { + "@capacitor/cli": "^7.4.3", + "@tailwindcss/vite": "^4.2.2", + "tailwindcss": "^4.2.2" + } +} diff --git a/frontend/pages/admin.vue b/frontend/pages/admin.vue new file mode 100644 index 0000000..b01b250 --- /dev/null +++ b/frontend/pages/admin.vue @@ -0,0 +1,687 @@ + + +