init
This commit is contained in:
26
backend/src/lib/auth.ts
Normal file
26
backend/src/lib/auth.ts
Normal 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
16
backend/src/lib/dedupe.ts
Normal 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(":");
|
||||
}
|
||||
|
||||
9
backend/src/lib/errors.ts
Normal file
9
backend/src/lib/errors.ts
Normal 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
86
backend/src/lib/mail.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
87
backend/src/lib/native-push.ts
Normal file
87
backend/src/lib/native-push.ts
Normal 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
56
backend/src/lib/push.ts
Normal 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
10
backend/src/lib/redis.ts
Normal 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();
|
||||
}
|
||||
52
backend/src/lib/settlement.ts
Normal file
52
backend/src/lib/settlement.ts
Normal 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user