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

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

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

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

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

View File

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

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

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

View File

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

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

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

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

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

View File

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