2026-01-20 13:58:13 -05:00
|
|
|
import pool from '../config/database';
|
|
|
|
|
|
|
|
|
|
// User types and queries
|
|
|
|
|
export interface User {
|
|
|
|
|
id: number;
|
|
|
|
|
email: string;
|
|
|
|
|
password_hash: string;
|
2026-01-20 21:15:04 -05:00
|
|
|
telegram_bot_token: string | null;
|
|
|
|
|
telegram_chat_id: string | null;
|
|
|
|
|
discord_webhook_url: string | null;
|
2026-01-20 13:58:13 -05:00
|
|
|
created_at: Date;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-20 21:15:04 -05:00
|
|
|
export interface NotificationSettings {
|
|
|
|
|
telegram_bot_token: string | null;
|
|
|
|
|
telegram_chat_id: string | null;
|
|
|
|
|
discord_webhook_url: string | null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-20 13:58:13 -05:00
|
|
|
export const userQueries = {
|
|
|
|
|
findByEmail: async (email: string): Promise<User | null> => {
|
|
|
|
|
const result = await pool.query(
|
|
|
|
|
'SELECT * FROM users WHERE email = $1',
|
|
|
|
|
[email]
|
|
|
|
|
);
|
|
|
|
|
return result.rows[0] || null;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
findById: async (id: number): Promise<User | null> => {
|
|
|
|
|
const result = await pool.query(
|
|
|
|
|
'SELECT * FROM users WHERE id = $1',
|
|
|
|
|
[id]
|
|
|
|
|
);
|
|
|
|
|
return result.rows[0] || null;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
create: async (email: string, passwordHash: string): Promise<User> => {
|
|
|
|
|
const result = await pool.query(
|
|
|
|
|
'INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING *',
|
|
|
|
|
[email, passwordHash]
|
|
|
|
|
);
|
|
|
|
|
return result.rows[0];
|
|
|
|
|
},
|
2026-01-20 21:15:04 -05:00
|
|
|
|
|
|
|
|
getNotificationSettings: async (id: number): Promise<NotificationSettings | null> => {
|
|
|
|
|
const result = await pool.query(
|
|
|
|
|
'SELECT telegram_bot_token, telegram_chat_id, discord_webhook_url FROM users WHERE id = $1',
|
|
|
|
|
[id]
|
|
|
|
|
);
|
|
|
|
|
return result.rows[0] || null;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
updateNotificationSettings: async (
|
|
|
|
|
id: number,
|
|
|
|
|
settings: Partial<NotificationSettings>
|
|
|
|
|
): Promise<NotificationSettings | null> => {
|
|
|
|
|
const fields: string[] = [];
|
|
|
|
|
const values: (string | null)[] = [];
|
|
|
|
|
let paramIndex = 1;
|
|
|
|
|
|
|
|
|
|
if (settings.telegram_bot_token !== undefined) {
|
|
|
|
|
fields.push(`telegram_bot_token = $${paramIndex++}`);
|
|
|
|
|
values.push(settings.telegram_bot_token);
|
|
|
|
|
}
|
|
|
|
|
if (settings.telegram_chat_id !== undefined) {
|
|
|
|
|
fields.push(`telegram_chat_id = $${paramIndex++}`);
|
|
|
|
|
values.push(settings.telegram_chat_id);
|
|
|
|
|
}
|
|
|
|
|
if (settings.discord_webhook_url !== undefined) {
|
|
|
|
|
fields.push(`discord_webhook_url = $${paramIndex++}`);
|
|
|
|
|
values.push(settings.discord_webhook_url);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (fields.length === 0) return null;
|
|
|
|
|
|
|
|
|
|
values.push(id.toString());
|
|
|
|
|
const result = await pool.query(
|
|
|
|
|
`UPDATE users SET ${fields.join(', ')} WHERE id = $${paramIndex}
|
|
|
|
|
RETURNING telegram_bot_token, telegram_chat_id, discord_webhook_url`,
|
|
|
|
|
values
|
|
|
|
|
);
|
|
|
|
|
return result.rows[0] || null;
|
|
|
|
|
},
|
2026-01-20 13:58:13 -05:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Product types and queries
|
2026-01-20 20:54:12 -05:00
|
|
|
export type StockStatus = 'in_stock' | 'out_of_stock' | 'unknown';
|
|
|
|
|
|
2026-01-20 13:58:13 -05:00
|
|
|
export interface Product {
|
|
|
|
|
id: number;
|
|
|
|
|
user_id: number;
|
|
|
|
|
url: string;
|
|
|
|
|
name: string | null;
|
|
|
|
|
image_url: string | null;
|
|
|
|
|
refresh_interval: number;
|
|
|
|
|
last_checked: Date | null;
|
2026-01-20 20:54:12 -05:00
|
|
|
stock_status: StockStatus;
|
2026-01-20 21:15:04 -05:00
|
|
|
price_drop_threshold: number | null;
|
|
|
|
|
notify_back_in_stock: boolean;
|
2026-01-20 13:58:13 -05:00
|
|
|
created_at: Date;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface ProductWithLatestPrice extends Product {
|
|
|
|
|
current_price: number | null;
|
|
|
|
|
currency: string | null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-20 19:32:25 -05:00
|
|
|
export interface SparklinePoint {
|
|
|
|
|
price: number;
|
|
|
|
|
recorded_at: Date;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface ProductWithSparkline extends ProductWithLatestPrice {
|
|
|
|
|
sparkline: SparklinePoint[];
|
|
|
|
|
price_change_7d: number | null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-20 13:58:13 -05:00
|
|
|
export const productQueries = {
|
|
|
|
|
findByUserId: async (userId: number): Promise<ProductWithLatestPrice[]> => {
|
|
|
|
|
const result = await pool.query(
|
|
|
|
|
`SELECT p.*, ph.price as current_price, ph.currency
|
|
|
|
|
FROM products p
|
|
|
|
|
LEFT JOIN LATERAL (
|
|
|
|
|
SELECT price, currency FROM price_history
|
|
|
|
|
WHERE product_id = p.id
|
|
|
|
|
ORDER BY recorded_at DESC
|
|
|
|
|
LIMIT 1
|
|
|
|
|
) ph ON true
|
|
|
|
|
WHERE p.user_id = $1
|
|
|
|
|
ORDER BY p.created_at DESC`,
|
|
|
|
|
[userId]
|
|
|
|
|
);
|
|
|
|
|
return result.rows;
|
|
|
|
|
},
|
|
|
|
|
|
2026-01-20 19:32:25 -05:00
|
|
|
findByUserIdWithSparkline: async (userId: number): Promise<ProductWithSparkline[]> => {
|
|
|
|
|
// Get all products with current price
|
|
|
|
|
const productsResult = await pool.query(
|
|
|
|
|
`SELECT p.*, ph.price as current_price, ph.currency
|
|
|
|
|
FROM products p
|
|
|
|
|
LEFT JOIN LATERAL (
|
|
|
|
|
SELECT price, currency FROM price_history
|
|
|
|
|
WHERE product_id = p.id
|
|
|
|
|
ORDER BY recorded_at DESC
|
|
|
|
|
LIMIT 1
|
|
|
|
|
) ph ON true
|
|
|
|
|
WHERE p.user_id = $1
|
|
|
|
|
ORDER BY p.created_at DESC`,
|
|
|
|
|
[userId]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const products = productsResult.rows;
|
|
|
|
|
if (products.length === 0) return [];
|
|
|
|
|
|
|
|
|
|
// Get sparkline data for all products (last 7 days)
|
|
|
|
|
const productIds = products.map((p: Product) => p.id);
|
|
|
|
|
const sparklineResult = await pool.query(
|
|
|
|
|
`SELECT product_id, price, recorded_at
|
|
|
|
|
FROM price_history
|
|
|
|
|
WHERE product_id = ANY($1)
|
|
|
|
|
AND recorded_at >= CURRENT_TIMESTAMP - INTERVAL '7 days'
|
|
|
|
|
ORDER BY product_id, recorded_at ASC`,
|
|
|
|
|
[productIds]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Group sparkline data by product
|
|
|
|
|
const sparklineMap = new Map<number, SparklinePoint[]>();
|
|
|
|
|
for (const row of sparklineResult.rows) {
|
|
|
|
|
const points = sparklineMap.get(row.product_id) || [];
|
|
|
|
|
points.push({ price: row.price, recorded_at: row.recorded_at });
|
|
|
|
|
sparklineMap.set(row.product_id, points);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Combine products with sparkline data
|
|
|
|
|
return products.map((product: ProductWithLatestPrice) => {
|
|
|
|
|
const sparkline = sparklineMap.get(product.id) || [];
|
|
|
|
|
let priceChange7d: number | null = null;
|
|
|
|
|
|
|
|
|
|
if (sparkline.length >= 2) {
|
|
|
|
|
const firstPrice = parseFloat(String(sparkline[0].price));
|
|
|
|
|
const lastPrice = parseFloat(String(sparkline[sparkline.length - 1].price));
|
|
|
|
|
if (firstPrice > 0) {
|
|
|
|
|
priceChange7d = ((lastPrice - firstPrice) / firstPrice) * 100;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
...product,
|
|
|
|
|
sparkline,
|
|
|
|
|
price_change_7d: priceChange7d,
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
|
2026-01-20 13:58:13 -05:00
|
|
|
findById: async (id: number, userId: number): Promise<ProductWithLatestPrice | null> => {
|
|
|
|
|
const result = await pool.query(
|
|
|
|
|
`SELECT p.*, ph.price as current_price, ph.currency
|
|
|
|
|
FROM products p
|
|
|
|
|
LEFT JOIN LATERAL (
|
|
|
|
|
SELECT price, currency FROM price_history
|
|
|
|
|
WHERE product_id = p.id
|
|
|
|
|
ORDER BY recorded_at DESC
|
|
|
|
|
LIMIT 1
|
|
|
|
|
) ph ON true
|
|
|
|
|
WHERE p.id = $1 AND p.user_id = $2`,
|
|
|
|
|
[id, userId]
|
|
|
|
|
);
|
|
|
|
|
return result.rows[0] || null;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
create: async (
|
|
|
|
|
userId: number,
|
|
|
|
|
url: string,
|
|
|
|
|
name: string | null,
|
|
|
|
|
imageUrl: string | null,
|
2026-01-20 20:54:12 -05:00
|
|
|
refreshInterval: number = 3600,
|
|
|
|
|
stockStatus: StockStatus = 'unknown'
|
2026-01-20 13:58:13 -05:00
|
|
|
): Promise<Product> => {
|
|
|
|
|
const result = await pool.query(
|
2026-01-20 20:54:12 -05:00
|
|
|
`INSERT INTO products (user_id, url, name, image_url, refresh_interval, stock_status)
|
|
|
|
|
VALUES ($1, $2, $3, $4, $5, $6)
|
2026-01-20 13:58:13 -05:00
|
|
|
RETURNING *`,
|
2026-01-20 20:54:12 -05:00
|
|
|
[userId, url, name, imageUrl, refreshInterval, stockStatus]
|
2026-01-20 13:58:13 -05:00
|
|
|
);
|
|
|
|
|
return result.rows[0];
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
update: async (
|
|
|
|
|
id: number,
|
|
|
|
|
userId: number,
|
2026-01-20 21:15:04 -05:00
|
|
|
updates: {
|
|
|
|
|
name?: string;
|
|
|
|
|
refresh_interval?: number;
|
|
|
|
|
price_drop_threshold?: number | null;
|
|
|
|
|
notify_back_in_stock?: boolean;
|
|
|
|
|
}
|
2026-01-20 13:58:13 -05:00
|
|
|
): Promise<Product | null> => {
|
|
|
|
|
const fields: string[] = [];
|
2026-01-20 21:15:04 -05:00
|
|
|
const values: (string | number | boolean | null)[] = [];
|
2026-01-20 13:58:13 -05:00
|
|
|
let paramIndex = 1;
|
|
|
|
|
|
|
|
|
|
if (updates.name !== undefined) {
|
|
|
|
|
fields.push(`name = $${paramIndex++}`);
|
|
|
|
|
values.push(updates.name);
|
|
|
|
|
}
|
|
|
|
|
if (updates.refresh_interval !== undefined) {
|
|
|
|
|
fields.push(`refresh_interval = $${paramIndex++}`);
|
|
|
|
|
values.push(updates.refresh_interval);
|
|
|
|
|
}
|
2026-01-20 21:15:04 -05:00
|
|
|
if (updates.price_drop_threshold !== undefined) {
|
|
|
|
|
fields.push(`price_drop_threshold = $${paramIndex++}`);
|
|
|
|
|
values.push(updates.price_drop_threshold);
|
|
|
|
|
}
|
|
|
|
|
if (updates.notify_back_in_stock !== undefined) {
|
|
|
|
|
fields.push(`notify_back_in_stock = $${paramIndex++}`);
|
|
|
|
|
values.push(updates.notify_back_in_stock);
|
|
|
|
|
}
|
2026-01-20 13:58:13 -05:00
|
|
|
|
|
|
|
|
if (fields.length === 0) return null;
|
|
|
|
|
|
|
|
|
|
values.push(id, userId);
|
|
|
|
|
const result = await pool.query(
|
|
|
|
|
`UPDATE products SET ${fields.join(', ')}
|
|
|
|
|
WHERE id = $${paramIndex++} AND user_id = $${paramIndex}
|
|
|
|
|
RETURNING *`,
|
|
|
|
|
values
|
|
|
|
|
);
|
|
|
|
|
return result.rows[0] || null;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
delete: async (id: number, userId: number): Promise<boolean> => {
|
|
|
|
|
const result = await pool.query(
|
|
|
|
|
'DELETE FROM products WHERE id = $1 AND user_id = $2',
|
|
|
|
|
[id, userId]
|
|
|
|
|
);
|
|
|
|
|
return (result.rowCount ?? 0) > 0;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
updateLastChecked: async (id: number): Promise<void> => {
|
|
|
|
|
await pool.query(
|
|
|
|
|
'UPDATE products SET last_checked = CURRENT_TIMESTAMP WHERE id = $1',
|
|
|
|
|
[id]
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
|
2026-01-20 20:54:12 -05:00
|
|
|
updateStockStatus: async (id: number, stockStatus: StockStatus): Promise<void> => {
|
|
|
|
|
await pool.query(
|
|
|
|
|
'UPDATE products SET stock_status = $1 WHERE id = $2',
|
|
|
|
|
[stockStatus, id]
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
|
2026-01-20 13:58:13 -05:00
|
|
|
findDueForRefresh: async (): Promise<Product[]> => {
|
|
|
|
|
const result = await pool.query(
|
|
|
|
|
`SELECT * FROM products
|
|
|
|
|
WHERE last_checked IS NULL
|
|
|
|
|
OR last_checked + (refresh_interval || ' seconds')::interval < CURRENT_TIMESTAMP`
|
|
|
|
|
);
|
|
|
|
|
return result.rows;
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Price History types and queries
|
|
|
|
|
export interface PriceHistory {
|
|
|
|
|
id: number;
|
|
|
|
|
product_id: number;
|
|
|
|
|
price: number;
|
|
|
|
|
currency: string;
|
|
|
|
|
recorded_at: Date;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const priceHistoryQueries = {
|
|
|
|
|
findByProductId: async (
|
|
|
|
|
productId: number,
|
|
|
|
|
days?: number
|
|
|
|
|
): Promise<PriceHistory[]> => {
|
|
|
|
|
let query = `
|
|
|
|
|
SELECT * FROM price_history
|
|
|
|
|
WHERE product_id = $1
|
|
|
|
|
`;
|
|
|
|
|
const values: (number | string)[] = [productId];
|
|
|
|
|
|
|
|
|
|
if (days) {
|
|
|
|
|
query += ` AND recorded_at >= CURRENT_TIMESTAMP - ($2 || ' days')::interval`;
|
|
|
|
|
values.push(days.toString());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
query += ' ORDER BY recorded_at ASC';
|
|
|
|
|
|
|
|
|
|
const result = await pool.query(query, values);
|
|
|
|
|
return result.rows;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
create: async (
|
|
|
|
|
productId: number,
|
|
|
|
|
price: number,
|
|
|
|
|
currency: string = 'USD'
|
|
|
|
|
): Promise<PriceHistory> => {
|
|
|
|
|
const result = await pool.query(
|
|
|
|
|
`INSERT INTO price_history (product_id, price, currency)
|
|
|
|
|
VALUES ($1, $2, $3)
|
|
|
|
|
RETURNING *`,
|
|
|
|
|
[productId, price, currency]
|
|
|
|
|
);
|
|
|
|
|
return result.rows[0];
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
getLatest: async (productId: number): Promise<PriceHistory | null> => {
|
|
|
|
|
const result = await pool.query(
|
|
|
|
|
`SELECT * FROM price_history
|
|
|
|
|
WHERE product_id = $1
|
|
|
|
|
ORDER BY recorded_at DESC
|
|
|
|
|
LIMIT 1`,
|
|
|
|
|
[productId]
|
|
|
|
|
);
|
|
|
|
|
return result.rows[0] || null;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
getStats: async (productId: number): Promise<{
|
|
|
|
|
min_price: number;
|
|
|
|
|
max_price: number;
|
|
|
|
|
avg_price: number;
|
|
|
|
|
price_count: number;
|
|
|
|
|
} | null> => {
|
|
|
|
|
const result = await pool.query(
|
|
|
|
|
`SELECT
|
|
|
|
|
MIN(price) as min_price,
|
|
|
|
|
MAX(price) as max_price,
|
|
|
|
|
AVG(price)::decimal(10,2) as avg_price,
|
|
|
|
|
COUNT(*) as price_count
|
|
|
|
|
FROM price_history
|
|
|
|
|
WHERE product_id = $1`,
|
|
|
|
|
[productId]
|
|
|
|
|
);
|
|
|
|
|
return result.rows[0] || null;
|
|
|
|
|
},
|
|
|
|
|
};
|