import { Database } from "bun:sqlite"; import { mkdirSync } from "node:fs"; import path from "node:path"; import { albums as initialAlbums } from "./data"; import { Album, Purchase, PurchaseStatus, TelegramApprover, TelegramUserSession } from "./types"; // Database path const dbPath = path.join(process.cwd(), "data", "parsa.db"); let db: Database | null = null; export function getDatabase(): Database { if (!db) { const dataDir = path.join(process.cwd(), "data"); mkdirSync(dataDir, { recursive: true }); db = new Database(dbPath, { create: true }); db.exec("PRAGMA journal_mode = WAL"); initializeDatabase(db); } return db; } function initializeDatabase(database: Database) { // Create albums table database.exec(` CREATE TABLE IF NOT EXISTS albums ( id TEXT PRIMARY KEY, title TEXT NOT NULL, coverImage TEXT NOT NULL, year INTEGER NOT NULL, genre TEXT NOT NULL, description TEXT NOT NULL, price REAL NOT NULL, tag TEXT NOT NULL DEFAULT 'Album', format TEXT NOT NULL DEFAULT 'mp3', bitrate TEXT NOT NULL DEFAULT '320kbps', songs TEXT NOT NULL, createdAt INTEGER DEFAULT (strftime('%s', 'now')), updatedAt INTEGER DEFAULT (strftime('%s', 'now')) ) `); // Create purchases table database.exec(` CREATE TABLE IF NOT EXISTS purchases ( id INTEGER PRIMARY KEY AUTOINCREMENT, albumId TEXT NOT NULL, albumIds TEXT, transactionId TEXT NOT NULL UNIQUE, customerName TEXT, email TEXT, phoneNumber TEXT, txReceipt TEXT, purchaseDate INTEGER NOT NULL, approvalStatus TEXT DEFAULT 'pending', paymentMethod TEXT DEFAULT 'card-to-card', reviewedByTelegramId TEXT, reviewedAt INTEGER, telegramUserId TEXT, telegramChatId TEXT, receiptType TEXT, receiptTelegramFileId TEXT, createdAt INTEGER DEFAULT (strftime('%s', 'now')), FOREIGN KEY (albumId) REFERENCES albums(id) ON DELETE CASCADE ) `); // Create payment authorities table for ZarinPal tracking database.exec(` CREATE TABLE IF NOT EXISTS payment_authorities ( id INTEGER PRIMARY KEY AUTOINCREMENT, authority TEXT NOT NULL UNIQUE, albumId TEXT NOT NULL, amount INTEGER NOT NULL, customerName TEXT, email TEXT, phoneNumber TEXT, status TEXT DEFAULT 'pending', refId TEXT, cardPan TEXT, fee INTEGER, createdAt INTEGER DEFAULT (strftime('%s', 'now')), verifiedAt INTEGER, FOREIGN KEY (albumId) REFERENCES albums(id) ON DELETE CASCADE ) `); database.exec(` CREATE TABLE IF NOT EXISTS telegram_approvers ( userId TEXT PRIMARY KEY, label TEXT NOT NULL DEFAULT '', createdAt INTEGER NOT NULL ) `); database.exec(` CREATE TABLE IF NOT EXISTS telegram_bot_settings ( id INTEGER PRIMARY KEY CHECK (id = 1), baseUrl TEXT NOT NULL DEFAULT 'https://api.telegram.org', botToken TEXT, greetingText TEXT NOT NULL DEFAULT 'Welcome! Choose albums from the catalog below.', cardNumber TEXT NOT NULL DEFAULT '', cardHolderName TEXT NOT NULL DEFAULT '', updatedAt INTEGER NOT NULL ) `); database.prepare(` INSERT OR IGNORE INTO telegram_bot_settings (id, baseUrl, botToken, updatedAt) VALUES (1, 'https://api.telegram.org', NULL, ?) `).run(Date.now()); database.exec(` CREATE TABLE IF NOT EXISTS telegram_user_sessions ( userId TEXT PRIMARY KEY, chatId TEXT NOT NULL, state TEXT NOT NULL DEFAULT 'browsing', cartAlbumIds TEXT NOT NULL DEFAULT '[]', displayName TEXT NOT NULL DEFAULT '', updatedAt INTEGER NOT NULL ) `); // Add columns if they don't exist (migration) try { database.exec(`ALTER TABLE purchases ADD COLUMN approvalStatus TEXT DEFAULT 'pending'`); } catch (e) { // Column already exists } try { database.exec(`ALTER TABLE purchases ADD COLUMN paymentMethod TEXT DEFAULT 'card-to-card'`); } catch (e) { // Column already exists } try { database.exec(`ALTER TABLE purchases ADD COLUMN reviewedByTelegramId TEXT`); } catch (e) { // Column already exists } try { database.exec(`ALTER TABLE purchases ADD COLUMN reviewedAt INTEGER`); } catch (e) { // Column already exists } for (const migration of [ `ALTER TABLE purchases ADD COLUMN albumIds TEXT`, `ALTER TABLE purchases ADD COLUMN telegramUserId TEXT`, `ALTER TABLE purchases ADD COLUMN telegramChatId TEXT`, `ALTER TABLE purchases ADD COLUMN receiptType TEXT`, `ALTER TABLE purchases ADD COLUMN receiptTelegramFileId TEXT`, `ALTER TABLE telegram_bot_settings ADD COLUMN greetingText TEXT NOT NULL DEFAULT 'Welcome! Choose albums from the catalog below.'`, `ALTER TABLE telegram_bot_settings ADD COLUMN cardNumber TEXT NOT NULL DEFAULT ''`, `ALTER TABLE telegram_bot_settings ADD COLUMN cardHolderName TEXT NOT NULL DEFAULT ''`, ]) { try { database.exec(migration); } catch { // Column already exists } } // Create indexes database.exec(` CREATE INDEX IF NOT EXISTS idx_purchases_albumId ON purchases(albumId); CREATE INDEX IF NOT EXISTS idx_purchases_transactionId ON purchases(transactionId); CREATE INDEX IF NOT EXISTS idx_purchases_approvalStatus ON purchases(approvalStatus); CREATE INDEX IF NOT EXISTS idx_payment_authorities_authority ON payment_authorities(authority); CREATE INDEX IF NOT EXISTS idx_payment_authorities_status ON payment_authorities(status); `); // Seed initial data if albums table is empty const count = database.prepare("SELECT COUNT(*) as count FROM albums").get() as { count: number; }; if (count.count === 0) { seedInitialData(database); } } function seedInitialData(database: Database) { const insert = database.prepare(` INSERT INTO albums (id, title, coverImage, year, genre, description, price, tag, format, bitrate, songs) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); const insertMany = database.transaction((albums: Album[]) => { for (const album of albums) { insert.run( album.id, album.title, album.coverImage, album.year, album.genre, album.description, album.price, album.tag, album.format, album.bitrate, JSON.stringify(album.songs), ); } }); insertMany(initialAlbums); } // Album operations export const albumDb = { getAll(): Album[] { const db = getDatabase(); const rows = db.prepare("SELECT * FROM albums ORDER BY year DESC").all(); return rows.map((row: any) => ({ ...row, songs: JSON.parse(row.songs), })); }, getById(id: string): Album | null { const db = getDatabase(); const row = db.prepare("SELECT * FROM albums WHERE id = ?").get(id) as any; if (!row) return null; return { ...row, songs: JSON.parse(row.songs), }; }, create(album: Album): void { const db = getDatabase(); db.prepare( ` INSERT INTO albums (id, title, coverImage, year, genre, description, price, tag, format, bitrate, songs) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, ).run( album.id, album.title, album.coverImage, album.year, album.genre, album.description, album.price, album.tag, album.format, album.bitrate, JSON.stringify(album.songs), ); }, update(id: string, album: Album): void { const db = getDatabase(); db.prepare( ` UPDATE albums SET title = ?, coverImage = ?, year = ?, genre = ?, description = ?, price = ?, tag = ?, format = ?, bitrate = ?, songs = ?, updatedAt = strftime('%s', 'now') WHERE id = ? `, ).run( album.title, album.coverImage, album.year, album.genre, album.description, album.price, album.tag, album.format, album.bitrate, JSON.stringify(album.songs), id, ); }, delete(id: string): void { const db = getDatabase(); db.prepare("DELETE FROM albums WHERE id = ?").run(id); }, }; // Purchase operations export const purchaseDb = { getAll(): Purchase[] { const db = getDatabase(); const rows = db .prepare("SELECT * FROM purchases ORDER BY purchaseDate DESC") .all(); return rows.map((row: any) => ({ id: row.id, albumId: row.albumId, albumIds: row.albumIds ? JSON.parse(row.albumIds) : [row.albumId], transactionId: row.transactionId, customerName: row.customerName, email: row.email, phoneNumber: row.phoneNumber, txReceipt: row.txReceipt, purchaseDate: new Date(row.purchaseDate), approvalStatus: row.approvalStatus, paymentMethod: row.paymentMethod, reviewedByTelegramId: row.reviewedByTelegramId || undefined, reviewedAt: row.reviewedAt ? new Date(row.reviewedAt) : undefined, telegramUserId: row.telegramUserId || undefined, telegramChatId: row.telegramChatId || undefined, receiptType: row.receiptType || undefined, receiptTelegramFileId: row.receiptTelegramFileId || undefined, })); }, getByAlbumId(albumId: string): Purchase[] { return this.getAll().filter((purchase) => (purchase.albumIds || [purchase.albumId]).includes(albumId), ); }, getByTransactionId(transactionId: string): Purchase | null { const db = getDatabase(); const row = db .prepare("SELECT * FROM purchases WHERE transactionId = ?") .get(transactionId) as any; if (!row) return null; return { id: row.id, albumId: row.albumId, albumIds: row.albumIds ? JSON.parse(row.albumIds) : [row.albumId], transactionId: row.transactionId, customerName: row.customerName, email: row.email, phoneNumber: row.phoneNumber, txReceipt: row.txReceipt, purchaseDate: new Date(row.purchaseDate), approvalStatus: row.approvalStatus, paymentMethod: row.paymentMethod, reviewedByTelegramId: row.reviewedByTelegramId || undefined, reviewedAt: row.reviewedAt ? new Date(row.reviewedAt) : undefined, telegramUserId: row.telegramUserId || undefined, telegramChatId: row.telegramChatId || undefined, receiptType: row.receiptType || undefined, receiptTelegramFileId: row.receiptTelegramFileId || undefined, }; }, getById(id: number): Purchase | null { const db = getDatabase(); const row = db.prepare("SELECT * FROM purchases WHERE id = ?").get(id) as any; if (!row) return null; return { id: row.id, albumId: row.albumId, albumIds: row.albumIds ? JSON.parse(row.albumIds) : [row.albumId], transactionId: row.transactionId, customerName: row.customerName, email: row.email, phoneNumber: row.phoneNumber, txReceipt: row.txReceipt, purchaseDate: new Date(row.purchaseDate), approvalStatus: row.approvalStatus, paymentMethod: row.paymentMethod, reviewedByTelegramId: row.reviewedByTelegramId || undefined, reviewedAt: row.reviewedAt ? new Date(row.reviewedAt) : undefined, telegramUserId: row.telegramUserId || undefined, telegramChatId: row.telegramChatId || undefined, receiptType: row.receiptType || undefined, receiptTelegramFileId: row.receiptTelegramFileId || undefined, }; }, create(purchase: Omit): Purchase { const db = getDatabase(); const result = db .prepare( ` INSERT INTO purchases ( albumId, albumIds, transactionId, customerName, email, phoneNumber, txReceipt, purchaseDate, approvalStatus, paymentMethod, telegramUserId, telegramChatId, receiptType, receiptTelegramFileId ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, ) .run( purchase.albumId, JSON.stringify(purchase.albumIds || [purchase.albumId]), purchase.transactionId, purchase.customerName || null, purchase.email || null, purchase.phoneNumber || null, purchase.txReceipt || null, purchase.purchaseDate instanceof Date ? purchase.purchaseDate.getTime() : purchase.purchaseDate, purchase.approvalStatus || 'pending', purchase.paymentMethod || 'card-to-card', purchase.telegramUserId || null, purchase.telegramChatId || null, purchase.receiptType || null, purchase.receiptTelegramFileId || null, ); return { id: result.lastInsertRowid as number, ...purchase, }; }, delete(id: number): void { const db = getDatabase(); db.prepare("DELETE FROM purchases WHERE id = ?").run(id); }, setStatus(id: number, status: PurchaseStatus, telegramUserId?: string): Purchase | null { const db = getDatabase(); const result = db.prepare(` UPDATE purchases SET approvalStatus = ?, reviewedByTelegramId = ?, reviewedAt = ? WHERE id = ? AND approvalStatus = 'pending' `).run(status, telegramUserId || null, Date.now(), id); return result.changes === 0 ? null : this.getById(id); }, }; export const telegramApproverDb = { getAll(): TelegramApprover[] { const rows = getDatabase() .prepare("SELECT userId, label, createdAt FROM telegram_approvers ORDER BY createdAt ASC") .all() as Array<{ userId: string; label: string; createdAt: number }>; return rows.map((row) => ({ userId: row.userId, label: row.label || undefined, createdAt: new Date(row.createdAt), })); }, isAuthorized(userId: string): boolean { return Boolean( getDatabase() .prepare("SELECT 1 FROM telegram_approvers WHERE userId = ?") .get(userId), ); }, replaceAll(approvers: TelegramApprover[]): void { const db = getDatabase(); const replace = db.transaction((items: TelegramApprover[]) => { db.exec("DELETE FROM telegram_approvers"); const insert = db.prepare( "INSERT INTO telegram_approvers (userId, label, createdAt) VALUES (?, ?, ?)", ); for (const approver of items) { insert.run(approver.userId, approver.label || "", Date.now()); } }); replace(approvers); }, }; export const telegramSettingsDb = { get(): { baseUrl: string; botToken: string | null; greetingText: string; cardNumber: string; cardHolderName: string; updatedAt: Date; } { const row = getDatabase() .prepare(` SELECT baseUrl, botToken, greetingText, cardNumber, cardHolderName, updatedAt FROM telegram_bot_settings WHERE id = 1 `) .get() as { baseUrl: string; botToken: string | null; greetingText: string; cardNumber: string; cardHolderName: string; updatedAt: number; }; return { baseUrl: row.baseUrl, botToken: row.botToken, greetingText: row.greetingText, cardNumber: row.cardNumber, cardHolderName: row.cardHolderName, updatedAt: new Date(row.updatedAt), }; }, update(settings: { baseUrl: string; botToken?: string; greetingText: string; cardNumber: string; cardHolderName: string; }): void { const db = getDatabase(); if (settings.botToken !== undefined) { db.prepare(` UPDATE telegram_bot_settings SET baseUrl = ?, botToken = ?, greetingText = ?, cardNumber = ?, cardHolderName = ?, updatedAt = ? WHERE id = 1 `).run( settings.baseUrl, settings.botToken, settings.greetingText, settings.cardNumber, settings.cardHolderName, Date.now(), ); return; } db.prepare(` UPDATE telegram_bot_settings SET baseUrl = ?, greetingText = ?, cardNumber = ?, cardHolderName = ?, updatedAt = ? WHERE id = 1 `).run( settings.baseUrl, settings.greetingText, settings.cardNumber, settings.cardHolderName, Date.now(), ); }, }; export const telegramSessionDb = { get(userId: string): TelegramUserSession | null { const row = getDatabase() .prepare("SELECT * FROM telegram_user_sessions WHERE userId = ?") .get(userId) as any; if (!row) return null; return { userId: row.userId, chatId: row.chatId, state: row.state, cartAlbumIds: JSON.parse(row.cartAlbumIds), displayName: row.displayName || undefined, updatedAt: new Date(row.updatedAt), }; }, upsert(session: Omit): TelegramUserSession { const db = getDatabase(); db.prepare(` INSERT INTO telegram_user_sessions (userId, chatId, state, cartAlbumIds, displayName, updatedAt) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(userId) DO UPDATE SET chatId = excluded.chatId, state = excluded.state, cartAlbumIds = excluded.cartAlbumIds, displayName = excluded.displayName, updatedAt = excluded.updatedAt `).run( session.userId, session.chatId, session.state, JSON.stringify(session.cartAlbumIds), session.displayName || "", Date.now(), ); return this.get(session.userId)!; }, };