566 lines
17 KiB
TypeScript
566 lines
17 KiB
TypeScript
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, "id">): 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, "updatedAt">): 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)!;
|
|
},
|
|
};
|