build 1
This commit is contained in:
37
backend/Dockerfile
Normal file
37
backend/Dockerfile
Normal file
@@ -0,0 +1,37 @@
|
||||
# ── Build stage ──────────────────────────────────────────────────────────────
|
||||
FROM node:20-slim AS builder
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
python3 make g++ \
|
||||
--no-install-recommends \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json tsconfig.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY src ./src
|
||||
RUN npm run build && npm prune --production
|
||||
|
||||
# ── Runtime stage ─────────────────────────────────────────────────────────────
|
||||
FROM node:20-slim
|
||||
|
||||
# Install Chromium and runtime dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
chromium \
|
||||
fonts-freefont-ttf \
|
||||
--no-install-recommends \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
|
||||
ENV NODE_ENV=production
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY package.json ./
|
||||
|
||||
EXPOSE 3001
|
||||
CMD ["node", "dist/index.js"]
|
||||
24
backend/package.json
Normal file
24
backend/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "ui-tracker-backend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^9.6.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.19.2",
|
||||
"puppeteer-core": "^22.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.10",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.14.0",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"typescript": "^5.4.5"
|
||||
}
|
||||
}
|
||||
57
backend/src/database.ts
Normal file
57
backend/src/database.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import path from 'path';
|
||||
|
||||
const DB_PATH = process.env.DATABASE_PATH || path.join(__dirname, '../../data/tracker.db');
|
||||
|
||||
let db: Database.Database;
|
||||
|
||||
export function getDb(): Database.Database {
|
||||
if (!db) {
|
||||
db = new Database(DB_PATH);
|
||||
db.pragma('journal_mode = WAL');
|
||||
initSchema(db);
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
function initSchema(db: Database.Database): void {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS watched_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
url TEXT NOT NULL UNIQUE,
|
||||
name TEXT,
|
||||
thumbnail_url TEXT,
|
||||
check_interval INTEGER NOT NULL DEFAULT 60,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
last_status TEXT NOT NULL DEFAULT 'unknown',
|
||||
alert_sent INTEGER NOT NULL DEFAULT 0,
|
||||
check_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_checked_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO settings (key, value) VALUES ('telegram_bot_token', '');
|
||||
INSERT OR IGNORE INTO settings (key, value) VALUES ('telegram_chat_id', '');
|
||||
`);
|
||||
}
|
||||
|
||||
export type StockStatus = 'in_stock' | 'sold_out' | 'unknown';
|
||||
|
||||
export interface WatchedItem {
|
||||
id: number;
|
||||
url: string;
|
||||
name: string | null;
|
||||
thumbnail_url: string | null;
|
||||
check_interval: number;
|
||||
is_active: number;
|
||||
last_status: StockStatus;
|
||||
alert_sent: number;
|
||||
check_count: number;
|
||||
last_checked_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
27
backend/src/index.ts
Normal file
27
backend/src/index.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import { getDb } from './database';
|
||||
import { initScheduler } from './scheduler';
|
||||
import itemsRouter from './routes/items';
|
||||
import settingsRouter from './routes/settings';
|
||||
|
||||
const app = express();
|
||||
const PORT = parseInt(process.env.PORT || '3001', 10);
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
app.use('/api/items', itemsRouter);
|
||||
app.use('/api/settings', settingsRouter);
|
||||
|
||||
app.get('/api/health', (_req, res) => {
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
// Boot sequence
|
||||
getDb();
|
||||
initScheduler();
|
||||
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`[Server] UI Tracker backend running on port ${PORT}`);
|
||||
});
|
||||
122
backend/src/routes/items.ts
Normal file
122
backend/src/routes/items.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { getDb, WatchedItem } from '../database';
|
||||
import { startItem, stopItem } from '../scheduler';
|
||||
import { checkStockStatus } from '../scraper';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// GET /api/items
|
||||
router.get('/', (_req: Request, res: Response) => {
|
||||
const items = getDb().prepare('SELECT * FROM watched_items ORDER BY created_at DESC').all();
|
||||
res.json(items);
|
||||
});
|
||||
|
||||
// POST /api/items
|
||||
router.post('/', async (req: Request, res: Response) => {
|
||||
const { url, check_interval } = req.body as { url?: string; check_interval?: number | string };
|
||||
|
||||
if (!url || typeof url !== 'string' || !url.startsWith('http')) {
|
||||
res.status(400).json({ error: 'A valid URL is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = Math.max(30, parseInt(String(check_interval ?? 60), 10) || 60);
|
||||
const db = getDb();
|
||||
|
||||
try {
|
||||
const item = db
|
||||
.prepare('INSERT INTO watched_items (url, check_interval) VALUES (?, ?) RETURNING *')
|
||||
.get(url.trim(), interval) as WatchedItem;
|
||||
|
||||
// Async initial scrape — populates name + thumbnail in background
|
||||
checkStockStatus(url.trim())
|
||||
.then(result => {
|
||||
const updates: Record<string, string | number> = { last_status: result.status };
|
||||
if (result.name) updates.name = result.name;
|
||||
if (result.thumbnail) updates.thumbnail_url = result.thumbnail;
|
||||
const set = Object.keys(updates).map(k => `${k} = @${k}`).join(', ');
|
||||
db.prepare(`UPDATE watched_items SET ${set} WHERE id = @id`).run({ ...updates, id: item.id });
|
||||
})
|
||||
.catch(err => console.error('[Items] Initial scrape failed:', err));
|
||||
|
||||
startItem(item);
|
||||
res.status(201).json(item);
|
||||
} catch (err: unknown) {
|
||||
const sqliteErr = err as { code?: string };
|
||||
if (sqliteErr.code === 'SQLITE_CONSTRAINT_UNIQUE') {
|
||||
res.status(409).json({ error: 'This URL is already being tracked' });
|
||||
return;
|
||||
}
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Failed to add item' });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/items/:id
|
||||
router.put('/:id', (req: Request, res: Response) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
const db = getDb();
|
||||
const item = db.prepare('SELECT * FROM watched_items WHERE id = ?').get(id) as WatchedItem | undefined;
|
||||
|
||||
if (!item) { res.status(404).json({ error: 'Item not found' }); return; }
|
||||
|
||||
const { check_interval, is_active } = req.body as { check_interval?: number | string; is_active?: boolean | number };
|
||||
|
||||
const newInterval = check_interval !== undefined
|
||||
? Math.max(30, parseInt(String(check_interval), 10) || item.check_interval)
|
||||
: item.check_interval;
|
||||
|
||||
const newActive = is_active !== undefined ? (is_active ? 1 : 0) : item.is_active;
|
||||
|
||||
db.prepare('UPDATE watched_items SET check_interval = ?, is_active = ? WHERE id = ?')
|
||||
.run(newInterval, newActive, id);
|
||||
|
||||
const updated = db.prepare('SELECT * FROM watched_items WHERE id = ?').get(id) as WatchedItem;
|
||||
|
||||
if (newActive && !item.is_active) {
|
||||
startItem(updated);
|
||||
} else if (!newActive && item.is_active) {
|
||||
stopItem(id);
|
||||
} else if (newActive && newInterval !== item.check_interval) {
|
||||
startItem(updated); // restart with new interval
|
||||
}
|
||||
|
||||
res.json(updated);
|
||||
});
|
||||
|
||||
// DELETE /api/items/:id
|
||||
router.delete('/:id', (req: Request, res: Response) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
stopItem(id);
|
||||
getDb().prepare('DELETE FROM watched_items WHERE id = ?').run(id);
|
||||
res.status(204).send();
|
||||
});
|
||||
|
||||
// POST /api/items/:id/pause
|
||||
router.post('/:id/pause', (req: Request, res: Response) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
const db = getDb();
|
||||
stopItem(id);
|
||||
db.prepare('UPDATE watched_items SET is_active = 0 WHERE id = ?').run(id);
|
||||
res.json(db.prepare('SELECT * FROM watched_items WHERE id = ?').get(id));
|
||||
});
|
||||
|
||||
// POST /api/items/:id/resume
|
||||
router.post('/:id/resume', (req: Request, res: Response) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
const db = getDb();
|
||||
db.prepare('UPDATE watched_items SET is_active = 1 WHERE id = ?').run(id);
|
||||
const item = db.prepare('SELECT * FROM watched_items WHERE id = ?').get(id) as WatchedItem;
|
||||
startItem(item);
|
||||
res.json(item);
|
||||
});
|
||||
|
||||
// POST /api/items/:id/reset — clears alert_sent so the next in-stock triggers a new alert
|
||||
router.post('/:id/reset', (req: Request, res: Response) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
const db = getDb();
|
||||
db.prepare('UPDATE watched_items SET alert_sent = 0 WHERE id = ?').run(id);
|
||||
res.json(db.prepare('SELECT * FROM watched_items WHERE id = ?').get(id));
|
||||
});
|
||||
|
||||
export default router;
|
||||
36
backend/src/routes/settings.ts
Normal file
36
backend/src/routes/settings.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { getDb } from '../database';
|
||||
import { testTelegramConnection } from '../telegram';
|
||||
|
||||
const router = Router();
|
||||
|
||||
function getAllSettings(): Record<string, string> {
|
||||
const rows = getDb().prepare('SELECT key, value FROM settings').all() as { key: string; value: string }[];
|
||||
return Object.fromEntries(rows.map(r => [r.key, r.value]));
|
||||
}
|
||||
|
||||
// GET /api/settings
|
||||
router.get('/', (_req: Request, res: Response) => {
|
||||
res.json(getAllSettings());
|
||||
});
|
||||
|
||||
// PUT /api/settings
|
||||
router.put('/', (req: Request, res: Response) => {
|
||||
const { telegram_bot_token, telegram_chat_id } = req.body as Record<string, string>;
|
||||
const db = getDb();
|
||||
|
||||
const upsert = db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)');
|
||||
|
||||
if (telegram_bot_token !== undefined) upsert.run('telegram_bot_token', telegram_bot_token);
|
||||
if (telegram_chat_id !== undefined) upsert.run('telegram_chat_id', telegram_chat_id);
|
||||
|
||||
res.json(getAllSettings());
|
||||
});
|
||||
|
||||
// POST /api/settings/test-telegram
|
||||
router.post('/test-telegram', async (_req: Request, res: Response) => {
|
||||
const result = await testTelegramConnection();
|
||||
res.status(result.success ? 200 : 400).json(result);
|
||||
});
|
||||
|
||||
export default router;
|
||||
85
backend/src/scheduler.ts
Normal file
85
backend/src/scheduler.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { getDb, WatchedItem } from './database';
|
||||
import { checkStockStatus } from './scraper';
|
||||
import { sendTelegramAlert } from './telegram';
|
||||
|
||||
const MIN_INTERVAL_SECONDS = 30;
|
||||
const timers = new Map<number, NodeJS.Timeout>();
|
||||
|
||||
async function runCheck(itemId: number): Promise<void> {
|
||||
const db = getDb();
|
||||
const item = db.prepare('SELECT * FROM watched_items WHERE id = ?').get(itemId) as WatchedItem | undefined;
|
||||
|
||||
if (!item || !item.is_active) return;
|
||||
|
||||
console.log(`[Scheduler] Checking item ${itemId} — ${item.name || item.url}`);
|
||||
|
||||
try {
|
||||
const result = await checkStockStatus(item.url);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
let alertSent = item.alert_sent;
|
||||
|
||||
// Fire alert only on transition to in_stock when no alert has been sent yet
|
||||
if (result.status === 'in_stock' && !item.alert_sent) {
|
||||
const displayName = result.name || item.name || item.url;
|
||||
await sendTelegramAlert(
|
||||
`🟢 <b>Back in Stock!</b>\n\n` +
|
||||
`<b>${displayName}</b>\n\n` +
|
||||
`<a href="${item.url}">Open in Store →</a>`
|
||||
);
|
||||
alertSent = 1;
|
||||
}
|
||||
|
||||
// Build update payload
|
||||
const updates: Record<string, string | number> = {
|
||||
last_status: result.status,
|
||||
alert_sent: alertSent,
|
||||
check_count: item.check_count + 1,
|
||||
last_checked_at: now,
|
||||
};
|
||||
|
||||
// Populate name and thumbnail on first successful scrape
|
||||
if (result.name && !item.name) updates.name = result.name;
|
||||
if (result.thumbnail && !item.thumbnail_url) updates.thumbnail_url = result.thumbnail;
|
||||
|
||||
const setClause = Object.keys(updates).map(k => `${k} = @${k}`).join(', ');
|
||||
db.prepare(`UPDATE watched_items SET ${setClause} WHERE id = @id`).run({ ...updates, id: itemId });
|
||||
|
||||
console.log(`[Scheduler] Item ${itemId} → ${result.status} (check #${item.check_count + 1})`);
|
||||
} catch (err) {
|
||||
console.error(`[Scheduler] Error checking item ${itemId}:`, err);
|
||||
// Still increment count so the UI shows activity
|
||||
db.prepare('UPDATE watched_items SET check_count = check_count + 1, last_checked_at = ? WHERE id = ?')
|
||||
.run(new Date().toISOString(), itemId);
|
||||
}
|
||||
}
|
||||
|
||||
export function startItem(item: WatchedItem): void {
|
||||
stopItem(item.id);
|
||||
|
||||
const intervalMs = Math.max(MIN_INTERVAL_SECONDS, item.check_interval) * 1000;
|
||||
const timer = setInterval(() => runCheck(item.id), intervalMs);
|
||||
timers.set(item.id, timer);
|
||||
|
||||
console.log(`[Scheduler] Started item ${item.id} — interval ${intervalMs / 1000}s`);
|
||||
}
|
||||
|
||||
export function stopItem(id: number): void {
|
||||
const timer = timers.get(id);
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
timers.delete(id);
|
||||
console.log(`[Scheduler] Stopped item ${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function initScheduler(): void {
|
||||
const db = getDb();
|
||||
const activeItems = db.prepare('SELECT * FROM watched_items WHERE is_active = 1').all() as WatchedItem[];
|
||||
|
||||
for (const item of activeItems) {
|
||||
startItem(item);
|
||||
}
|
||||
|
||||
console.log(`[Scheduler] Initialized — ${activeItems.length} active item(s)`);
|
||||
}
|
||||
113
backend/src/scraper.ts
Normal file
113
backend/src/scraper.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import puppeteer from 'puppeteer-core';
|
||||
import type { StockStatus } from './database';
|
||||
|
||||
export interface ScrapeResult {
|
||||
status: StockStatus;
|
||||
name?: string;
|
||||
thumbnail?: string;
|
||||
}
|
||||
|
||||
// Limit concurrent Puppeteer instances to avoid OOM on Unraid
|
||||
class Semaphore {
|
||||
private permits: number;
|
||||
private queue: Array<() => void> = [];
|
||||
|
||||
constructor(permits: number) {
|
||||
this.permits = permits;
|
||||
}
|
||||
|
||||
acquire(): Promise<void> {
|
||||
if (this.permits > 0) {
|
||||
this.permits--;
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise(resolve => this.queue.push(resolve));
|
||||
}
|
||||
|
||||
release(): void {
|
||||
const next = this.queue.shift();
|
||||
if (next) {
|
||||
next();
|
||||
} else {
|
||||
this.permits++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const scrapeSemaphore = new Semaphore(2);
|
||||
|
||||
export async function checkStockStatus(url: string): Promise<ScrapeResult> {
|
||||
await scrapeSemaphore.acquire();
|
||||
|
||||
const executablePath = process.env.PUPPETEER_EXECUTABLE_PATH || '/usr/bin/chromium';
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
executablePath,
|
||||
headless: true,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-accelerated-2d-canvas',
|
||||
'--disable-gpu',
|
||||
'--no-first-run',
|
||||
'--no-zygote',
|
||||
],
|
||||
});
|
||||
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
|
||||
// Spoof navigator to reduce bot detection
|
||||
await page.evaluateOnNewDocument(() => {
|
||||
Object.defineProperty(navigator, 'webdriver', { get: () => false });
|
||||
});
|
||||
|
||||
await page.setUserAgent(
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'
|
||||
);
|
||||
await page.setViewport({ width: 1280, height: 800 });
|
||||
|
||||
await page.goto(url, { waitUntil: 'networkidle2', timeout: 45000 });
|
||||
|
||||
// Wait for React hydration
|
||||
await new Promise(r => setTimeout(r, 2500));
|
||||
|
||||
const result = await page.evaluate(() => {
|
||||
let status: 'in_stock' | 'sold_out' | 'unknown' = 'unknown';
|
||||
|
||||
// Check all spans for the known button text
|
||||
const spans = document.querySelectorAll('span');
|
||||
for (const span of spans) {
|
||||
const text = span.textContent?.trim();
|
||||
if (text === 'Add to Cart') { status = 'in_stock'; break; }
|
||||
if (text === 'Sold Out') { status = 'sold_out'; break; }
|
||||
}
|
||||
|
||||
// Product name: og:title is most reliable for single-product pages
|
||||
let name: string | undefined;
|
||||
const ogTitle = document.querySelector('meta[property="og:title"]') as HTMLMetaElement | null;
|
||||
if (ogTitle?.content) {
|
||||
name = ogTitle.content.replace(' – Ubiquiti Store', '').replace(' | Ubiquiti Store', '').trim();
|
||||
}
|
||||
if (!name) {
|
||||
const h1 = document.querySelector('h1');
|
||||
name = h1?.textContent?.trim();
|
||||
}
|
||||
|
||||
// Thumbnail: og:image is the most reliable
|
||||
let thumbnail: string | undefined;
|
||||
const ogImage = document.querySelector('meta[property="og:image"]') as HTMLMetaElement | null;
|
||||
if (ogImage?.content) {
|
||||
thumbnail = ogImage.content;
|
||||
}
|
||||
|
||||
return { status, name, thumbnail };
|
||||
});
|
||||
|
||||
return result as ScrapeResult;
|
||||
} finally {
|
||||
await browser.close();
|
||||
scrapeSemaphore.release();
|
||||
}
|
||||
}
|
||||
67
backend/src/telegram.ts
Normal file
67
backend/src/telegram.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { getDb } from './database';
|
||||
|
||||
function getCredentials(): { token: string; chatId: string } | null {
|
||||
const db = getDb();
|
||||
const token = (db.prepare('SELECT value FROM settings WHERE key = ?').get('telegram_bot_token') as { value: string } | undefined)?.value;
|
||||
const chatId = (db.prepare('SELECT value FROM settings WHERE key = ?').get('telegram_chat_id') as { value: string } | undefined)?.value;
|
||||
|
||||
if (!token || !chatId) return null;
|
||||
return { token, chatId };
|
||||
}
|
||||
|
||||
export async function sendTelegramAlert(message: string): Promise<void> {
|
||||
const creds = getCredentials();
|
||||
|
||||
if (!creds) {
|
||||
console.warn('[Telegram] Bot token or chat ID not configured — skipping alert');
|
||||
return;
|
||||
}
|
||||
|
||||
const url = `https://api.telegram.org/bot${creds.token}/sendMessage`;
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
chat_id: creds.chatId,
|
||||
text: message,
|
||||
parse_mode: 'HTML',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
console.error('[Telegram] Failed to send alert:', res.status, body);
|
||||
} else {
|
||||
console.log('[Telegram] Alert sent successfully');
|
||||
}
|
||||
}
|
||||
|
||||
export async function testTelegramConnection(): Promise<{ success: boolean; error?: string }> {
|
||||
const creds = getCredentials();
|
||||
|
||||
if (!creds) {
|
||||
return { success: false, error: 'Bot token or chat ID is not configured.' };
|
||||
}
|
||||
|
||||
try {
|
||||
const url = `https://api.telegram.org/bot${creds.token}/sendMessage`;
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
chat_id: creds.chatId,
|
||||
text: '🔔 <b>UI Stock Tracker</b> — Test notification! Alerts are working correctly.',
|
||||
parse_mode: 'HTML',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json() as { description?: string };
|
||||
return { success: false, error: body.description || 'Unknown Telegram error' };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
return { success: false, error: String(err) };
|
||||
}
|
||||
}
|
||||
18
backend/tsconfig.json
Normal file
18
backend/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2020"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user