← back to Handbag Authentication
db/schema.js
295 lines
const sqlite3 = require('sqlite3').verbose();
const path = require('path');
const fs = require('fs');
class Database {
constructor(dbPath = './data/handbags.db') {
// Ensure data directory exists
const dir = path.dirname(dbPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
this.db = new sqlite3.Database(dbPath);
this.init();
}
init() {
this.db.serialize(() => {
// Main listings table
this.db.run(`
CREATE TABLE IF NOT EXISTS listings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
external_id TEXT UNIQUE,
source TEXT NOT NULL,
title TEXT NOT NULL,
brand TEXT,
model TEXT,
price_jpy REAL NOT NULL,
price_usd REAL,
currency TEXT DEFAULT 'JPY',
condition TEXT,
image_url TEXT,
thumbnail_url TEXT,
product_url TEXT,
listing_type TEXT CHECK(listing_type IN ('auction', 'buy', 'both')),
auction_end_date TEXT,
location TEXT,
seller_name TEXT,
description TEXT,
crawled_at TEXT DEFAULT CURRENT_TIMESTAMP,
is_active INTEGER DEFAULT 1,
UNIQUE(external_id, source)
)
`);
// US market price comparisons
this.db.run(`
CREATE TABLE IF NOT EXISTS us_comparisons (
id INTEGER PRIMARY KEY AUTOINCREMENT,
listing_id INTEGER,
us_source TEXT NOT NULL,
us_price_usd REAL,
us_url TEXT,
us_condition TEXT,
comparison_date TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(listing_id) REFERENCES listings(id) ON DELETE CASCADE
)
`);
// Deal analysis
this.db.run(`
CREATE TABLE IF NOT EXISTS deal_analysis (
id INTEGER PRIMARY KEY AUTOINCREMENT,
listing_id INTEGER UNIQUE,
avg_us_price REAL,
min_us_price REAL,
max_us_price REAL,
deal_percentage REAL,
confidence_score REAL,
is_deal INTEGER DEFAULT 0,
analysis_date TEXT DEFAULT CURRENT_TIMESTAMP,
ai_notes TEXT,
FOREIGN KEY(listing_id) REFERENCES listings(id) ON DELETE CASCADE
)
`);
// Crawl history
this.db.run(`
CREATE TABLE IF NOT EXISTS crawl_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source TEXT NOT NULL,
start_time TEXT DEFAULT CURRENT_TIMESTAMP,
end_time TEXT,
items_found INTEGER DEFAULT 0,
items_added INTEGER DEFAULT 0,
items_updated INTEGER DEFAULT 0,
status TEXT CHECK(status IN ('running', 'completed', 'failed')),
error_message TEXT
)
`);
// Indexes for performance
this.db.run(`CREATE INDEX IF NOT EXISTS idx_brand ON listings(brand)`);
this.db.run(`CREATE INDEX IF NOT EXISTS idx_price ON listings(price_usd)`);
this.db.run(`CREATE INDEX IF NOT EXISTS idx_listing_type ON listings(listing_type)`);
this.db.run(`CREATE INDEX IF NOT EXISTS idx_crawled_at ON listings(crawled_at)`);
this.db.run(`CREATE INDEX IF NOT EXISTS idx_deal_percentage ON deal_analysis(deal_percentage)`);
this.db.run(`CREATE INDEX IF NOT EXISTS idx_is_deal ON deal_analysis(is_deal)`);
});
}
// Check if a listing is new (not seen before)
isNewListing(listing, callback) {
const { external_id, source } = listing;
const sql = `SELECT id FROM listings WHERE external_id = ? AND source = ?`;
this.db.get(sql, [external_id, source], (err, row) => {
if (err) {
callback(err, false);
} else {
// If row exists, it's NOT new. If no row, it IS new.
callback(null, !row);
}
});
}
// Insert or update listing
upsertListing(listing, callback) {
const {
external_id, source, title, brand, model, price_jpy, price_usd, currency,
condition, image_url, thumbnail_url, product_url, listing_type,
auction_end_date, location, seller_name, description, is_new
} = listing;
const sql = `
INSERT INTO listings (
external_id, source, title, brand, model, price_jpy, price_usd, currency,
condition, image_url, thumbnail_url, product_url, listing_type,
auction_end_date, location, seller_name, description, is_new
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(external_id, source) DO UPDATE SET
title=excluded.title,
price_jpy=excluded.price_jpy,
price_usd=excluded.price_usd,
condition=excluded.condition,
image_url=excluded.image_url,
thumbnail_url=excluded.thumbnail_url,
is_active=1,
is_new=0,
crawled_at=CURRENT_TIMESTAMP
`;
this.db.run(sql, [
external_id, source, title, brand, model, price_jpy, price_usd, currency,
condition, image_url, thumbnail_url, product_url, listing_type,
auction_end_date, location, seller_name, description, is_new || 0
], callback);
}
// Get all active listings with deal info
getListings(filters = {}, callback) {
let sql = `
SELECT
l.*,
d.deal_percentage,
d.avg_us_price,
d.is_deal,
d.confidence_score,
d.ai_notes
FROM listings l
LEFT JOIN deal_analysis d ON l.id = d.listing_id
WHERE l.is_active = 1
`;
const params = [];
if (filters.brand) {
sql += ` AND LOWER(l.brand) LIKE LOWER(?)`;
params.push(`%${filters.brand}%`);
}
if (filters.minPrice) {
sql += ` AND l.price_usd >= ?`;
params.push(filters.minPrice);
}
if (filters.maxPrice) {
sql += ` AND l.price_usd <= ?`;
params.push(filters.maxPrice);
}
if (filters.listing_type) {
sql += ` AND l.listing_type = ?`;
params.push(filters.listing_type);
}
if (filters.dealOnly) {
sql += ` AND d.is_deal = 1`;
}
if (filters.minDealPercentage) {
sql += ` AND d.deal_percentage >= ?`;
params.push(filters.minDealPercentage);
}
if (filters.newOnly) {
sql += ` AND l.is_new = 1`;
}
// Sorting
const sortMap = {
'deal': 'd.deal_percentage DESC',
'price_low': 'l.price_usd ASC',
'price_high': 'l.price_usd DESC',
'date': 'l.crawled_at DESC',
'new': 'l.is_new DESC, l.crawled_at DESC',
'type': 'l.listing_type ASC'
};
sql += ` ORDER BY ${sortMap[filters.sort] || 'd.deal_percentage DESC'}`;
if (filters.limit) {
sql += ` LIMIT ?`;
params.push(filters.limit);
}
this.db.all(sql, params, callback);
}
// Save deal analysis
saveDealAnalysis(analysis, callback) {
const {
listing_id, avg_us_price, min_us_price, max_us_price,
deal_percentage, confidence_score, is_deal, ai_notes
} = analysis;
const sql = `
INSERT INTO deal_analysis (
listing_id, avg_us_price, min_us_price, max_us_price,
deal_percentage, confidence_score, is_deal, ai_notes
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(listing_id) DO UPDATE SET
avg_us_price=excluded.avg_us_price,
min_us_price=excluded.min_us_price,
max_us_price=excluded.max_us_price,
deal_percentage=excluded.deal_percentage,
confidence_score=excluded.confidence_score,
is_deal=excluded.is_deal,
ai_notes=excluded.ai_notes,
analysis_date=CURRENT_TIMESTAMP
`;
this.db.run(sql, [
listing_id, avg_us_price, min_us_price, max_us_price,
deal_percentage, confidence_score, is_deal, ai_notes
], callback);
}
// Save US comparison
saveUSComparison(comparison, callback) {
const { listing_id, us_source, us_price_usd, us_url, us_condition } = comparison;
const sql = `
INSERT INTO us_comparisons (listing_id, us_source, us_price_usd, us_url, us_condition)
VALUES (?, ?, ?, ?, ?)
`;
this.db.run(sql, [listing_id, us_source, us_price_usd, us_url, us_condition], callback);
}
// Start crawl session
startCrawl(source, callback) {
const sql = `INSERT INTO crawl_history (source, status) VALUES (?, 'running')`;
this.db.run(sql, [source], function(err) {
callback(err, this.lastID);
});
}
// End crawl session
endCrawl(crawl_id, stats, callback) {
const sql = `
UPDATE crawl_history
SET end_time=CURRENT_TIMESTAMP,
items_found=?,
items_added=?,
items_updated=?,
status=?
WHERE id=?
`;
this.db.run(sql, [
stats.items_found,
stats.items_added,
stats.items_updated,
stats.status,
crawl_id
], callback);
}
close() {
this.db.close();
}
}
module.exports = Database;