feat: add Telegram album purchase bot

This commit is contained in:
2026-08-21 18:07:50 +03:30
parent 9478aa319f
commit 1e7b752532
21 changed files with 1399 additions and 459 deletions
+6
View File
@@ -1,3 +1,9 @@
# Public application URL (Telegram requires HTTPS)
NEXT_PUBLIC_APP_URL=https://podzahr.com
# Telegram webhook authentication (the bot URL and token are configured in Admin → Purchases)
TELEGRAM_WEBHOOK_SECRET=replace-with-a-long-random-secret
# ZarinPal Configuration # ZarinPal Configuration
ZARINPAL_MERCHANT_ID=your-merchant-id-here ZARINPAL_MERCHANT_ID=your-merchant-id-here
+25 -42
View File
@@ -1,60 +1,43 @@
# Multi-stage build for Next.js application with Bun ARG BUN_VERSION=1.3.11
# Stage 1: Dependencies FROM oven/bun:${BUN_VERSION}-alpine AS base
FROM oven/bun:1-alpine AS deps
WORKDIR /app WORKDIR /app
# Copy package files ENV NEXT_TELEMETRY_DISABLED=1
COPY package.json bun.lockb* ./
# Install dependencies with Bun FROM base AS dependencies
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile RUN bun install --frozen-lockfile
# Stage 2: Builder FROM base AS builder
FROM oven/bun:1-alpine AS builder
WORKDIR /app
# Copy dependencies from deps stage ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY --from=dependencies /app/node_modules ./node_modules
COPY . . COPY . .
# Set environment variables for build # package.json forces the Next.js CLI to execute with Bun.
ENV NEXT_TELEMETRY_DISABLED=1 RUN bun run build
ENV NODE_ENV=production
# Build the application with --bun flag to use Bun runtime FROM base AS runner
RUN bun --bun run build
# Stage 3: Runner ENV NODE_ENV=production \
FROM oven/bun:1-alpine AS runner HOSTNAME=0.0.0.0 \
WORKDIR /app PORT=3000
ENV NODE_ENV=production COPY --from=builder --chown=bun:bun /app/public ./public
ENV NEXT_TELEMETRY_DISABLED=1 COPY --from=builder --chown=bun:bun /app/.next/standalone ./
COPY --from=builder --chown=bun:bun /app/.next/static ./.next/static
# Create a non-root user # bun:sqlite writes the database and WAL files here.
RUN addgroup --system --gid 1001 nodejs RUN mkdir -p /app/data && chown -R bun:bun /app/data
RUN adduser --system --uid 1001 nextjs
# Copy necessary files from builder USER bun
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
# Create data directory for SQLite database
RUN mkdir -p /app/data && chown -R nextjs:nodejs /app/data
# Change ownership to nextjs user
RUN chown -R nextjs:nodejs /app
# Switch to non-root user
USER nextjs
# Expose port 3000
EXPOSE 3000 EXPOSE 3000
ENV PORT=3000 HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
ENV HOSTNAME="0.0.0.0" CMD bun -e "fetch('http://127.0.0.1:3000').then(response => process.exit(response.ok ? 0 : 1)).catch(() => process.exit(1))"
# Start the application with Bun runtime CMD ["bun", "server.js"]
CMD ["bun", "--bun", "run", "server.js"]
+25 -6
View File
@@ -45,8 +45,7 @@ A modern, interactive website showcasing a progressive rock composer and produce
### Prerequisites ### Prerequisites
- Node.js 18+ - Bun 1.3+
- npm or yarn
### Installation ### Installation
@@ -54,13 +53,13 @@ A modern, interactive website showcasing a progressive rock composer and produce
2. Install dependencies: 2. Install dependencies:
```bash ```bash
npm install bun install
``` ```
3. Run the development server: 3. Run the development server:
```bash ```bash
npm run dev bun run dev
``` ```
4. Open [http://localhost:3000](http://localhost:3000) in your browser 4. Open [http://localhost:3000](http://localhost:3000) in your browser
@@ -197,13 +196,33 @@ export const albums: Album[] = [
## Build for Production ## Build for Production
```bash ```bash
npm run build bun run build
npm start bun run start
``` ```
## Telegram Purchase Approval Bot
The Telegram bot works as both a customer storefront and an approval channel. Customers can browse albums, add several albums to a cart, receive the configured card details, and submit either a text transaction ID or a receipt screenshot. After approval, the bot uploads every purchased track directly into the customer's Telegram chat.
Authorized reviewers receive pending purchases with **Approve** and **Reject** buttons. They can also send `/pending` to list outstanding purchases or `/id` to see their Telegram user ID.
1. Create a bot with BotFather and copy `.env.example` to `.env`.
2. Set a long random `TELEGRAM_WEBHOOK_SECRET`. `NEXT_PUBLIC_APP_URL` defaults to `https://podzahr.com` and can be overridden if needed.
3. Deploy the application.
4. In **Admin → Purchases**, set the Telegram API base URL, paste the BotFather token, customize the greeting, and enter the payment card number and cardholder name. The saved token is never returned to the browser again.
5. Click **Register webhook**.
6. Each reviewer must start a private chat with the bot and send `/id`.
7. Enter the reviewers' comma-separated Telegram user IDs in the same admin settings panel and save.
Telegram calls `/api/telegram/webhook` with the configured secret. Every approval callback also verifies that the sender is still present in the SQLite-backed approver list.
Album delivery uses the full track files configured for each album in the admin panel. The application downloads each file and uploads it to Telegram as a document after the purchase is approved.
## Technologies Used ## Technologies Used
- **Next.js 15**: React framework with App Router - **Next.js 15**: React framework with App Router
- **Bun**: JavaScript runtime, package manager, and native SQLite driver
- **SQLite**: Persistent album, purchase, and payment data via `bun:sqlite`
- **TypeScript**: Type-safe development - **TypeScript**: Type-safe development
- **Tailwind CSS**: Utility-first CSS framework - **Tailwind CSS**: Utility-first CSS framework
- **Framer Motion**: Animation library - **Framer Motion**: Animation library
+24 -26
View File
@@ -10,7 +10,6 @@ import AdminLayout from '@/components/AdminLayout';
export default function AdminDashboard() { export default function AdminDashboard() {
const { albums } = useAlbums(); const { albums } = useAlbums();
const [purchases, setPurchases] = useState<Purchase[]>([]);
const [stats, setStats] = useState({ const [stats, setStats] = useState({
totalAlbums: 0, totalAlbums: 0,
totalPurchases: 0, totalPurchases: 0,
@@ -19,33 +18,27 @@ export default function AdminDashboard() {
}); });
useEffect(() => { useEffect(() => {
// Load purchases from localStorage const loadStats = async () => {
const savedPurchases = localStorage.getItem('purchases'); const response = await fetch('/api/purchases');
if (savedPurchases) { if (!response.ok) return;
const parsedPurchases = JSON.parse(savedPurchases); const allPurchases = await response.json() as Purchase[];
setPurchases(parsedPurchases); const totalRevenue = allPurchases
.filter((purchase) => purchase.approvalStatus === 'approved')
// Calculate stats .reduce((total, purchase) => {
const totalRevenue = parsedPurchases.reduce((total: number, purchase: Purchase) => { const ids = purchase.albumIds || [purchase.albumId];
const album = albums.find((a) => a.id === purchase.albumId); return total + albums
return total + (album?.price || 0); .filter((album) => ids.includes(album.id))
.reduce((subtotal, album) => subtotal + album.price, 0);
}, 0); }, 0);
setStats({ setStats({
totalAlbums: albums.length, totalAlbums: albums.length,
totalPurchases: parsedPurchases.length, totalPurchases: allPurchases.length,
totalRevenue, totalRevenue,
recentPurchases: parsedPurchases.slice(-5).reverse(), recentPurchases: allPurchases.slice(0, 5),
}); });
} else { };
setStats({ void loadStats();
totalAlbums: albums.length, }, [albums]);
totalPurchases: 0,
totalRevenue: 0,
recentPurchases: [],
});
}
}, []);
const statCards = [ const statCards = [
{ {
@@ -124,21 +117,26 @@ export default function AdminDashboard() {
{stats.recentPurchases.length > 0 ? ( {stats.recentPurchases.length > 0 ? (
<div className="space-y-3"> <div className="space-y-3">
{stats.recentPurchases.map((purchase) => { {stats.recentPurchases.map((purchase) => {
const album = albums.find((a) => a.id === purchase.albumId); const ids = purchase.albumIds || [purchase.albumId];
const purchaseAlbums = albums.filter((album) => ids.includes(album.id));
return ( return (
<div <div
key={purchase.transactionId} key={purchase.transactionId}
className="flex items-center justify-between p-4 bg-paper-light border-2 border-paper-brown/30" className="flex items-center justify-between p-4 bg-paper-light border-2 border-paper-brown/30"
> >
<div> <div>
<p className="text-paper-dark font-medium">{album?.title || 'Unknown Album'}</p> <p className="text-paper-dark font-medium">
{purchaseAlbums.map((album) => album.title).join(', ') || 'Unknown Album'}
</p>
<p className="text-sm text-paper-gray"> <p className="text-sm text-paper-gray">
{new Date(purchase.purchaseDate).toLocaleDateString()} at{' '} {new Date(purchase.purchaseDate).toLocaleDateString()} at{' '}
{new Date(purchase.purchaseDate).toLocaleTimeString()} {new Date(purchase.purchaseDate).toLocaleTimeString()}
</p> </p>
</div> </div>
<div className="text-right"> <div className="text-right">
<p className="text-paper-brown font-bold">{formatPrice(album?.price || 0)}</p> <p className="text-paper-brown font-bold">
{formatPrice(purchaseAlbums.reduce((total, album) => total + album.price, 0))}
</p>
<p className="text-xs text-paper-gray font-mono">{purchase.transactionId.slice(0, 12)}...</p> <p className="text-xs text-paper-gray font-mono">{purchase.transactionId.slice(0, 12)}...</p>
</div> </div>
</div> </div>
+249 -15
View File
@@ -2,7 +2,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { FaDownload, FaSearch, FaCheck, FaTimes, FaClock } from 'react-icons/fa'; import { FaDownload, FaSearch, FaCheck, FaTimes, FaClock, FaTelegramPlane, FaSave } from 'react-icons/fa';
import { useAlbums } from '@/lib/AlbumsContext'; import { useAlbums } from '@/lib/AlbumsContext';
import { Purchase } from '@/lib/types'; import { Purchase } from '@/lib/types';
import { formatPrice } from '@/lib/utils'; import { formatPrice } from '@/lib/utils';
@@ -14,9 +14,20 @@ export default function AdminPurchasesPage() {
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [filterStatus, setFilterStatus] = useState<'all' | 'pending' | 'approved' | 'rejected'>('all'); const [filterStatus, setFilterStatus] = useState<'all' | 'pending' | 'approved' | 'rejected'>('all');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [telegramIds, setTelegramIds] = useState('');
const [telegramBaseUrl, setTelegramBaseUrl] = useState('https://api.telegram.org');
const [telegramBotToken, setTelegramBotToken] = useState('');
const [telegramGreeting, setTelegramGreeting] = useState('Welcome! Choose albums from the catalog below.');
const [telegramCardNumber, setTelegramCardNumber] = useState('');
const [telegramCardHolder, setTelegramCardHolder] = useState('');
const [telegramConfigured, setTelegramConfigured] = useState(false);
const [telegramWebhookSecretConfigured, setTelegramWebhookSecretConfigured] = useState(false);
const [telegramSaving, setTelegramSaving] = useState(false);
const [telegramRegistering, setTelegramRegistering] = useState(false);
const [telegramMessage, setTelegramMessage] = useState('');
const fetchPurchases = async () => { const fetchPurchases = async (showLoading = true) => {
setLoading(true); if (showLoading) setLoading(true);
try { try {
const response = await fetch('/api/purchases'); const response = await fetch('/api/purchases');
if (response.ok) { if (response.ok) {
@@ -26,14 +37,93 @@ export default function AdminPurchasesPage() {
} catch (error) { } catch (error) {
console.error('Error fetching purchases:', error); console.error('Error fetching purchases:', error);
} finally { } finally {
setLoading(false); if (showLoading) setLoading(false);
} }
}; };
useEffect(() => { useEffect(() => {
fetchPurchases(); fetchPurchases();
const refreshTimer = window.setInterval(() => fetchPurchases(false), 10_000);
fetch('/api/telegram/settings')
.then(async (response) => {
if (!response.ok) throw new Error('Failed to load Telegram settings');
return response.json();
})
.then((data) => {
setTelegramIds(data.approvers.map((item: { userId: string }) => item.userId).join(', '));
setTelegramBaseUrl(data.baseUrl || 'https://api.telegram.org');
setTelegramGreeting(data.greetingText || 'Welcome! Choose albums from the catalog below.');
setTelegramCardNumber(data.cardNumber || '');
setTelegramCardHolder(data.cardHolderName || '');
setTelegramConfigured(Boolean(data.botConfigured));
setTelegramWebhookSecretConfigured(Boolean(data.webhookSecretConfigured));
})
.catch((error) => {
console.error('Error fetching Telegram settings:', error);
setTelegramMessage('Could not load Telegram settings.');
});
return () => window.clearInterval(refreshTimer);
}, []); }, []);
const saveTelegramSettings = async () => {
const ids = telegramIds
.split(/[\s,]+/)
.map((id) => id.trim())
.filter(Boolean);
if (ids.some((id) => !/^\d+$/.test(id))) {
setTelegramMessage('Telegram user IDs must contain digits only.');
return;
}
setTelegramSaving(true);
setTelegramMessage('');
try {
const response = await fetch('/api/telegram/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
approvers: ids.map((userId) => ({ userId })),
baseUrl: telegramBaseUrl,
greetingText: telegramGreeting,
cardNumber: telegramCardNumber,
cardHolderName: telegramCardHolder,
...(telegramBotToken.trim() ? { botToken: telegramBotToken.trim() } : {}),
}),
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Failed to save Telegram approvers');
setTelegramIds(data.approvers.map((item: { userId: string }) => item.userId).join(', '));
setTelegramBaseUrl(data.baseUrl);
setTelegramGreeting(data.greetingText);
setTelegramCardNumber(data.cardNumber);
setTelegramCardHolder(data.cardHolderName);
setTelegramBotToken('');
setTelegramConfigured(Boolean(data.botConfigured));
setTelegramWebhookSecretConfigured(Boolean(data.webhookSecretConfigured));
setTelegramMessage('Telegram settings saved.');
} catch (error) {
setTelegramMessage(error instanceof Error ? error.message : 'Failed to save Telegram approvers.');
} finally {
setTelegramSaving(false);
}
};
const registerTelegramWebhook = async () => {
setTelegramRegistering(true);
setTelegramMessage('');
try {
const response = await fetch('/api/telegram/settings', { method: 'POST' });
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Failed to register Telegram webhook');
setTelegramMessage(data.message);
} catch (error) {
setTelegramMessage(error instanceof Error ? error.message : 'Failed to register Telegram webhook.');
} finally {
setTelegramRegistering(false);
}
};
const handleApprove = async (purchaseId: number) => { const handleApprove = async (purchaseId: number) => {
try { try {
const response = await fetch(`/api/purchases/${purchaseId}/approve`, { const response = await fetch(`/api/purchases/${purchaseId}/approve`, {
@@ -63,9 +153,10 @@ export default function AdminPurchasesPage() {
}; };
const filteredPurchases = purchases.filter((purchase) => { const filteredPurchases = purchases.filter((purchase) => {
const album = albums.find((a) => a.id === purchase.albumId); const purchaseAlbumIds = purchase.albumIds || [purchase.albumId];
const purchaseAlbums = albums.filter((album) => purchaseAlbumIds.includes(album.id));
const matchesSearch = const matchesSearch =
album?.title.toLowerCase().includes(searchTerm.toLowerCase()) || purchaseAlbums.some((album) => album.title.toLowerCase().includes(searchTerm.toLowerCase())) ||
purchase.transactionId.toLowerCase().includes(searchTerm.toLowerCase()) || purchase.transactionId.toLowerCase().includes(searchTerm.toLowerCase()) ||
purchase.customerName?.toLowerCase().includes(searchTerm.toLowerCase()) || purchase.customerName?.toLowerCase().includes(searchTerm.toLowerCase()) ||
purchase.email?.toLowerCase().includes(searchTerm.toLowerCase()); purchase.email?.toLowerCase().includes(searchTerm.toLowerCase());
@@ -79,21 +170,24 @@ export default function AdminPurchasesPage() {
const approvedPurchases = purchases.filter(p => p.approvalStatus === 'approved'); const approvedPurchases = purchases.filter(p => p.approvalStatus === 'approved');
const totalRevenue = approvedPurchases.reduce((total, purchase) => { const totalRevenue = approvedPurchases.reduce((total, purchase) => {
const album = albums.find((a) => a.id === purchase.albumId); const purchaseAlbumIds = purchase.albumIds || [purchase.albumId];
return total + (album?.price || 0); return total + albums
.filter((album) => purchaseAlbumIds.includes(album.id))
.reduce((subtotal, album) => subtotal + album.price, 0);
}, 0); }, 0);
const exportToCSV = () => { const exportToCSV = () => {
const headers = ['Date', 'Time', 'Transaction ID', 'Album', 'Price', 'Customer', 'Email', 'Phone', 'Status', 'Payment Method']; const headers = ['Date', 'Time', 'Transaction ID', 'Album', 'Price', 'Customer', 'Email', 'Phone', 'Status', 'Payment Method'];
const rows = purchases.map((purchase) => { const rows = purchases.map((purchase) => {
const album = albums.find((a) => a.id === purchase.albumId); const purchaseAlbumIds = purchase.albumIds || [purchase.albumId];
const purchaseAlbums = albums.filter((album) => purchaseAlbumIds.includes(album.id));
const date = new Date(purchase.purchaseDate); const date = new Date(purchase.purchaseDate);
return [ return [
date.toLocaleDateString(), date.toLocaleDateString(),
date.toLocaleTimeString(), date.toLocaleTimeString(),
purchase.transactionId, purchase.transactionId,
album?.title || 'Unknown', purchaseAlbums.map((album) => album.title).join(' + ') || 'Unknown',
album?.price || 0, purchaseAlbums.reduce((total, album) => total + album.price, 0),
purchase.customerName || '', purchase.customerName || '',
purchase.email || '', purchase.email || '',
purchase.phoneNumber || '', purchase.phoneNumber || '',
@@ -161,6 +255,133 @@ export default function AdminPurchasesPage() {
</motion.button> </motion.button>
</div> </div>
<div className="paper-card p-5 mb-6">
<div className="space-y-4">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<FaTelegramPlane className="text-paper-brown" />
<label htmlFor="telegram-approvers" className="font-semibold text-paper-dark">
Telegram purchase approvers
</label>
<span className={`text-xs px-2 py-1 border ${
telegramConfigured
? 'bg-green-100 border-green-400 text-green-700'
: 'bg-orange-100 border-orange-400 text-orange-700'
}`}>
{telegramConfigured ? 'Bot configured' : 'Bot token missing'}
</span>
</div>
<div className="grid md:grid-cols-2 gap-4 mb-4">
<div>
<label htmlFor="telegram-base-url" className="block text-sm font-medium text-paper-dark mb-1">
Telegram API base URL
</label>
<input
id="telegram-base-url"
type="url"
value={telegramBaseUrl}
onChange={(event) => setTelegramBaseUrl(event.target.value)}
placeholder="https://api.telegram.org"
className="w-full px-4 py-3 bg-paper-light border-2 border-paper-brown focus:border-paper-dark focus:outline-none text-paper-dark placeholder-paper-gray shadow-paper"
/>
</div>
<div>
<label htmlFor="telegram-bot-token" className="block text-sm font-medium text-paper-dark mb-1">
Bot token
</label>
<input
id="telegram-bot-token"
type="password"
value={telegramBotToken}
onChange={(event) => setTelegramBotToken(event.target.value)}
placeholder={telegramConfigured ? 'Saved — leave blank to keep it' : 'Paste the BotFather token'}
autoComplete="new-password"
className="w-full px-4 py-3 bg-paper-light border-2 border-paper-brown focus:border-paper-dark focus:outline-none text-paper-dark placeholder-paper-gray shadow-paper"
/>
</div>
</div>
<div className="mb-4">
<label htmlFor="telegram-greeting" className="block text-sm font-medium text-paper-dark mb-1">
Greeting message
</label>
<textarea
id="telegram-greeting"
value={telegramGreeting}
onChange={(event) => setTelegramGreeting(event.target.value)}
rows={3}
className="w-full px-4 py-3 bg-paper-light border-2 border-paper-brown focus:border-paper-dark focus:outline-none text-paper-dark placeholder-paper-gray shadow-paper"
/>
</div>
<div className="grid md:grid-cols-2 gap-4 mb-4">
<div>
<label htmlFor="telegram-card-number" className="block text-sm font-medium text-paper-dark mb-1">
Payment card number
</label>
<input
id="telegram-card-number"
type="text"
value={telegramCardNumber}
onChange={(event) => setTelegramCardNumber(event.target.value)}
placeholder="6037 9975 1234 5678"
className="w-full px-4 py-3 bg-paper-light border-2 border-paper-brown focus:border-paper-dark focus:outline-none text-paper-dark placeholder-paper-gray shadow-paper"
/>
</div>
<div>
<label htmlFor="telegram-card-holder" className="block text-sm font-medium text-paper-dark mb-1">
Cardholder name
</label>
<input
id="telegram-card-holder"
type="text"
value={telegramCardHolder}
onChange={(event) => setTelegramCardHolder(event.target.value)}
placeholder="Account holder name"
className="w-full px-4 py-3 bg-paper-light border-2 border-paper-brown focus:border-paper-dark focus:outline-none text-paper-dark placeholder-paper-gray shadow-paper"
/>
</div>
</div>
<label htmlFor="telegram-approvers" className="block text-sm font-medium text-paper-dark mb-1">
Authorized Telegram user IDs
</label>
<input
id="telegram-approvers"
type="text"
value={telegramIds}
onChange={(event) => setTelegramIds(event.target.value)}
placeholder="123456789, 987654321"
className="w-full px-4 py-3 bg-paper-light border-2 border-paper-brown focus:border-paper-dark focus:outline-none text-paper-dark placeholder-paper-gray shadow-paper"
/>
<p className="text-xs text-paper-gray mt-2">
Comma-separated Telegram user IDs. A user can send /id to the bot to find their ID.
</p>
{!telegramWebhookSecretConfigured && (
<p className="text-xs text-orange-700 mt-2">
TELEGRAM_WEBHOOK_SECRET must be set on the server before registering the webhook.
</p>
)}
{telegramMessage && <p className="text-sm text-paper-dark mt-2">{telegramMessage}</p>}
</div>
<div className="flex flex-wrap gap-3">
<button
onClick={saveTelegramSettings}
disabled={telegramSaving}
className="px-5 py-3 bg-paper-brown hover:bg-paper-dark border-2 border-paper-dark text-paper-light font-semibold disabled:opacity-50 flex items-center justify-center gap-2"
>
<FaSave />
{telegramSaving ? 'Saving...' : 'Save Telegram settings'}
</button>
<button
onClick={registerTelegramWebhook}
disabled={telegramRegistering || !telegramConfigured || !telegramWebhookSecretConfigured}
className="px-5 py-3 bg-paper-light hover:bg-paper-sand border-2 border-paper-brown text-paper-dark font-semibold disabled:opacity-50 flex items-center justify-center gap-2"
>
<FaTelegramPlane />
{telegramRegistering ? 'Registering...' : 'Register webhook'}
</button>
</div>
</div>
</div>
{/* Filters */} {/* Filters */}
<div className="mb-6 space-y-4"> <div className="mb-6 space-y-4">
<div className="flex gap-2"> <div className="flex gap-2">
@@ -218,7 +439,8 @@ export default function AdminPurchasesPage() {
</thead> </thead>
<tbody> <tbody>
{filteredPurchases.map((purchase, index) => { {filteredPurchases.map((purchase, index) => {
const album = albums.find((a) => a.id === purchase.albumId); const purchaseAlbumIds = purchase.albumIds || [purchase.albumId];
const purchaseAlbums = albums.filter((album) => purchaseAlbumIds.includes(album.id));
const date = new Date(purchase.purchaseDate); const date = new Date(purchase.purchaseDate);
return ( return (
@@ -249,8 +471,13 @@ export default function AdminPurchasesPage() {
<div className="text-xs text-paper-gray">{purchase.phoneNumber}</div> <div className="text-xs text-paper-gray">{purchase.phoneNumber}</div>
</td> </td>
<td className="p-4"> <td className="p-4">
<div className="text-paper-dark font-medium">{album?.title || 'Unknown'}</div> <div className="text-paper-dark font-medium">
<div className="text-xs text-paper-gray">{album?.songs.length} tracks</div> {purchaseAlbums.map((album) => album.title).join(', ') || 'Unknown'}
</div>
<div className="text-xs text-paper-gray">
{purchaseAlbums.length} album{purchaseAlbums.length === 1 ? '' : 's'} {' '}
{purchaseAlbums.reduce((total, album) => total + album.songs.length, 0)} tracks
</div>
</td> </td>
<td className="p-4 text-center"> <td className="p-4 text-center">
<span className="text-xs text-paper-brown bg-paper-brown/10 px-2 py-1 border border-paper-brown"> <span className="text-xs text-paper-brown bg-paper-brown/10 px-2 py-1 border border-paper-brown">
@@ -259,9 +486,16 @@ export default function AdminPurchasesPage() {
</td> </td>
<td className="p-4 text-center"> <td className="p-4 text-center">
{getStatusBadge(purchase.approvalStatus)} {getStatusBadge(purchase.approvalStatus)}
{purchase.reviewedByTelegramId && (
<div className="text-xs text-paper-gray mt-1">
Telegram: {purchase.reviewedByTelegramId}
</div>
)}
</td> </td>
<td className="p-4 text-right"> <td className="p-4 text-right">
<span className="text-paper-brown font-bold">{formatPrice(album?.price || 0)}</span> <span className="text-paper-brown font-bold">
{formatPrice(purchaseAlbums.reduce((total, album) => total + album.price, 0))}
</span>
</td> </td>
<td className="p-4"> <td className="p-4">
{purchase.approvalStatus === 'pending' && ( {purchase.approvalStatus === 'pending' && (
+1 -1
View File
@@ -47,7 +47,7 @@ export default function AlbumDetailPage() {
const apiPurchases = await response.json(); const apiPurchases = await response.json();
// Only include approved purchases for determining access // Only include approved purchases for determining access
const approvedPurchases = apiPurchases.filter((p: Purchase) => p.approvalStatus === 'approved'); const approvedPurchases = apiPurchases.filter((p: Purchase) => p.approvalStatus === 'approved');
setPurchasedAlbums(approvedPurchases.map((p: Purchase) => p.albumId)); setPurchasedAlbums(approvedPurchases.flatMap((p: Purchase) => p.albumIds || [p.albumId]));
} }
} catch (error) { } catch (error) {
console.error('Error fetching purchases:', error); console.error('Error fetching purchases:', error);
+20 -12
View File
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { getDatabase } from '@/lib/db'; import { purchaseDb } from '@/lib/db';
import { notifyCustomerOfDecision } from '@/lib/telegram';
export async function PATCH( export async function PATCH(
request: NextRequest, request: NextRequest,
@@ -16,25 +17,32 @@ export async function PATCH(
); );
} }
const db = getDatabase(); const existing = purchaseDb.getById(purchaseId);
if (!existing) {
// Update purchase status to approved
const result = db.prepare(`
UPDATE purchases
SET approvalStatus = 'approved'
WHERE id = ?
`).run(purchaseId);
if (result.changes === 0) {
return NextResponse.json( return NextResponse.json(
{ error: 'Purchase not found' }, { error: 'Purchase not found' },
{ status: 404 } { status: 404 }
); );
} }
if (existing.approvalStatus !== 'pending') {
return NextResponse.json(
{ error: `Purchase is already ${existing.approvalStatus}` },
{ status: 409 }
);
}
const purchase = purchaseDb.setStatus(purchaseId, 'approved');
if (!purchase) {
return NextResponse.json({ error: 'Purchase was reviewed concurrently' }, { status: 409 });
}
void notifyCustomerOfDecision(purchase).catch((error) => {
console.error('Failed to notify Telegram customer:', error);
});
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
message: 'Purchase approved successfully' message: 'Purchase approved successfully',
purchase,
}); });
} catch (error: any) { } catch (error: any) {
console.error('Error approving purchase:', error); console.error('Error approving purchase:', error);
+20 -12
View File
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { getDatabase } from '@/lib/db'; import { purchaseDb } from '@/lib/db';
import { notifyCustomerOfDecision } from '@/lib/telegram';
export async function PATCH( export async function PATCH(
request: NextRequest, request: NextRequest,
@@ -16,25 +17,32 @@ export async function PATCH(
); );
} }
const db = getDatabase(); const existing = purchaseDb.getById(purchaseId);
if (!existing) {
// Update purchase status to rejected
const result = db.prepare(`
UPDATE purchases
SET approvalStatus = 'rejected'
WHERE id = ?
`).run(purchaseId);
if (result.changes === 0) {
return NextResponse.json( return NextResponse.json(
{ error: 'Purchase not found' }, { error: 'Purchase not found' },
{ status: 404 } { status: 404 }
); );
} }
if (existing.approvalStatus !== 'pending') {
return NextResponse.json(
{ error: `Purchase is already ${existing.approvalStatus}` },
{ status: 409 }
);
}
const purchase = purchaseDb.setStatus(purchaseId, 'rejected');
if (!purchase) {
return NextResponse.json({ error: 'Purchase was reviewed concurrently' }, { status: 409 });
}
void notifyCustomerOfDecision(purchase).catch((error) => {
console.error('Failed to notify Telegram customer:', error);
});
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
message: 'Purchase rejected successfully' message: 'Purchase rejected successfully',
purchase,
}); });
} catch (error: any) { } catch (error: any) {
console.error('Error rejecting purchase:', error); console.error('Error rejecting purchase:', error);
+5
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { purchaseDb } from '@/lib/db'; import { purchaseDb } from '@/lib/db';
import { Purchase } from '@/lib/types'; import { Purchase } from '@/lib/types';
import { notifyPurchaseApprovers } from '@/lib/telegram';
// GET - Get all purchases // GET - Get all purchases
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
@@ -54,6 +55,10 @@ export async function POST(request: NextRequest) {
}; };
const created = purchaseDb.create(purchase); const created = purchaseDb.create(purchase);
// Telegram delivery must not make a successfully recorded purchase fail.
void notifyPurchaseApprovers(created).catch((error) => {
console.error('Failed to notify Telegram approvers:', error);
});
return NextResponse.json(created, { status: 201 }); return NextResponse.json(created, { status: 201 });
} catch (error) { } catch (error) {
console.error('Error creating purchase:', error); console.error('Error creating purchase:', error);
+99
View File
@@ -0,0 +1,99 @@
import { NextRequest, NextResponse } from "next/server";
import { telegramApproverDb, telegramSettingsDb } from "@/lib/db";
import { isTelegramConfigured, registerTelegramWebhook } from "@/lib/telegram";
import { TelegramApprover } from "@/lib/types";
export const dynamic = "force-dynamic";
export async function GET() {
const settings = telegramSettingsDb.get();
return NextResponse.json({
approvers: telegramApproverDb.getAll(),
baseUrl: settings.baseUrl,
greetingText: settings.greetingText,
cardNumber: settings.cardNumber,
cardHolderName: settings.cardHolderName,
botConfigured: isTelegramConfigured(),
webhookSecretConfigured: Boolean(process.env.TELEGRAM_WEBHOOK_SECRET),
});
}
export async function PUT(request: NextRequest) {
try {
const body = (await request.json()) as {
approvers?: TelegramApprover[];
baseUrl?: string;
botToken?: string;
greetingText?: string;
cardNumber?: string;
cardHolderName?: string;
};
if (!Array.isArray(body.approvers)) {
return NextResponse.json({ error: "approvers must be an array" }, { status: 400 });
}
const seen = new Set<string>();
const approvers: TelegramApprover[] = [];
for (const item of body.approvers) {
const userId = String(item.userId || "").trim();
if (!/^\d+$/.test(userId)) {
return NextResponse.json({ error: `Invalid Telegram user ID: ${userId}` }, { status: 400 });
}
if (!seen.has(userId)) {
seen.add(userId);
approvers.push({ userId, label: String(item.label || "").trim().slice(0, 100) });
}
}
const baseUrl = String(body.baseUrl || "https://api.telegram.org").trim().replace(/\/$/, "");
let parsedUrl: URL;
try {
parsedUrl = new URL(baseUrl);
} catch {
return NextResponse.json({ error: "Invalid Telegram API base URL" }, { status: 400 });
}
if (!["http:", "https:"].includes(parsedUrl.protocol) || parsedUrl.username || parsedUrl.password) {
return NextResponse.json({ error: "Telegram API base URL must use HTTP or HTTPS without credentials" }, { status: 400 });
}
const botToken = body.botToken?.trim();
const greetingText = String(body.greetingText || "").trim().slice(0, 1000);
const cardNumber = String(body.cardNumber || "").trim().slice(0, 100);
const cardHolderName = String(body.cardHolderName || "").trim().slice(0, 200);
if (!greetingText) {
return NextResponse.json({ error: "Greeting message is required" }, { status: 400 });
}
telegramApproverDb.replaceAll(approvers);
telegramSettingsDb.update({
baseUrl,
greetingText,
cardNumber,
cardHolderName,
...(botToken ? { botToken } : {}),
});
return NextResponse.json({
approvers: telegramApproverDb.getAll(),
baseUrl: telegramSettingsDb.get().baseUrl,
greetingText,
cardNumber,
cardHolderName,
botConfigured: isTelegramConfigured(),
webhookSecretConfigured: Boolean(process.env.TELEGRAM_WEBHOOK_SECRET),
});
} catch (error) {
console.error("Telegram settings update failed:", error);
return NextResponse.json({ error: "Failed to update Telegram settings" }, { status: 500 });
}
}
export async function POST() {
try {
await registerTelegramWebhook();
return NextResponse.json({ message: "Telegram webhook registered successfully" });
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to register Telegram webhook";
console.error("Telegram webhook registration failed:", error);
return NextResponse.json({ error: message }, { status: 502 });
}
}
+32
View File
@@ -0,0 +1,32 @@
import { timingSafeEqual } from "node:crypto";
import { NextRequest, NextResponse } from "next/server";
import { handleTelegramUpdate, TelegramUpdate } from "@/lib/telegram";
export const dynamic = "force-dynamic";
function secretMatches(provided: string, expected: string): boolean {
const left = Buffer.from(provided);
const right = Buffer.from(expected);
return left.length === right.length && timingSafeEqual(left, right);
}
export async function POST(request: NextRequest) {
const expectedSecret = process.env.TELEGRAM_WEBHOOK_SECRET;
if (!expectedSecret) {
return NextResponse.json({ error: "Telegram webhook is not configured" }, { status: 503 });
}
const providedSecret = request.headers.get("x-telegram-bot-api-secret-token") || "";
if (!secretMatches(providedSecret, expectedSecret)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const update = (await request.json()) as TelegramUpdate;
await handleTelegramUpdate(update);
return NextResponse.json({ ok: true });
} catch (error) {
console.error("Telegram webhook error:", error);
return NextResponse.json({ error: "Failed to process Telegram update" }, { status: 500 });
}
}
+1
View File
@@ -5,6 +5,7 @@
@layer base { @layer base {
body { body {
@apply bg-paper-light text-paper-dark min-h-screen; @apply bg-paper-light text-paper-dark min-h-screen;
font-family: Vazirmatn, Tahoma, Arial, sans-serif;
background-image: background-image:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' /%3E%3C/filter%3E%3Crect width='100' height='100' filter='url(%23noise)' opacity='0.05'/%3E%3C/svg%3E"); url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' /%3E%3C/filter%3E%3Crect width='100' height='100' filter='url(%23noise)' opacity='0.05'/%3E%3C/svg%3E");
} }
+1 -7
View File
@@ -1,15 +1,9 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { Vazirmatn } from 'next/font/google';
import './globals.css'; import './globals.css';
import { CartProvider } from '@/lib/CartContext'; import { CartProvider } from '@/lib/CartContext';
import { AdminProvider } from '@/lib/AdminContext'; import { AdminProvider } from '@/lib/AdminContext';
import { AlbumsProvider } from '@/lib/AlbumsContext'; import { AlbumsProvider } from '@/lib/AlbumsContext';
const vazirmatn = Vazirmatn({
subsets: ['latin', 'arabic'],
display: 'swap',
});
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Podzahr - Parsa Sadatie Music', title: 'Podzahr - Parsa Sadatie Music',
description: 'Explore progressive rock albums by composer and producer Parsa Sadatie (@parsadat). Intricate compositions, powerful instrumentation, and sonic landscapes.', description: 'Explore progressive rock albums by composer and producer Parsa Sadatie (@parsadat). Intricate compositions, powerful instrumentation, and sonic landscapes.',
@@ -23,7 +17,7 @@ export default function RootLayout({
}>) { }>) {
return ( return (
<html lang="en"> <html lang="en">
<body className={vazirmatn.className}> <body>
<AdminProvider> <AdminProvider>
<AlbumsProvider> <AlbumsProvider>
<CartProvider>{children}</CartProvider> <CartProvider>{children}</CartProvider>
+1 -1
View File
@@ -35,7 +35,7 @@ export default function Home() {
const apiPurchases = await response.json(); const apiPurchases = await response.json();
// Only include approved purchases for determining access // Only include approved purchases for determining access
const approvedPurchases = apiPurchases.filter((p: Purchase) => p.approvalStatus === 'approved'); const approvedPurchases = apiPurchases.filter((p: Purchase) => p.approvalStatus === 'approved');
setPurchasedAlbums(approvedPurchases.map((p: Purchase) => p.albumId)); setPurchasedAlbums(approvedPurchases.flatMap((p: Purchase) => p.albumIds || [p.albumId]));
} }
} catch (error) { } catch (error) {
console.error('Error fetching purchases:', error); console.error('Error fetching purchases:', error);
+15 -16
View File
@@ -1,34 +1,33 @@
version: '3.8'
services: services:
app: app:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: parsa-music-shop container_name: parsa-music-shop
init: true
ports: ports:
- "3000:3000" - "3000:3000"
environment: environment:
- NODE_ENV=production NODE_ENV: production
- NEXT_PUBLIC_APP_URL=http://localhost:3000 NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-https://podzahr.com}
TELEGRAM_WEBHOOK_SECRET: ${TELEGRAM_WEBHOOK_SECRET:-}
ZARINPAL_MERCHANT_ID: ${ZARINPAL_MERCHANT_ID:-}
# AWS S3 Configuration (optional) # AWS S3 Configuration (optional)
- AWS_REGION=${AWS_REGION:-ir-thr-at1} AWS_REGION: ${AWS_REGION:-ir-thr-at1}
- AWS_S3_ENDPOINT=${AWS_S3_ENDPOINT} AWS_S3_ENDPOINT: ${AWS_S3_ENDPOINT:-}
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID} AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-}
- AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY} AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-}
- AWS_S3_BUCKET=${AWS_S3_BUCKET} AWS_S3_BUCKET: ${AWS_S3_BUCKET:-}
volumes: volumes:
- ./data:/app/data - app-data:/app/data
restart: unless-stopped restart: unless-stopped
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks: networks:
- parsa-network - parsa-network
networks: networks:
parsa-network: parsa-network:
driver: bridge driver: bridge
volumes:
app-data:
name: podzahr-data
+299 -50
View File
@@ -1,39 +1,29 @@
import { Database } from "bun:sqlite"; import { Database } from "bun:sqlite";
import path from "path"; import { mkdirSync } from "node:fs";
import path from "node:path";
import { albums as initialAlbums } from "./data"; import { albums as initialAlbums } from "./data";
import { Album, Purchase } from "./types"; import { Album, Purchase, PurchaseStatus, TelegramApprover, TelegramUserSession } from "./types";
// Database path // Database path
const dbPath = path.join(process.cwd(), "data", "parsa.db"); const dbPath = path.join(process.cwd(), "data", "parsa.db");
// Initialize database let db: Database | null = null;
let db: any;
function createDatabase() { export function getDatabase(): Database {
// Use Bun's native SQLite
const { Database } = require("bun:sqlite");
return new Database(dbPath, { create: true });
}
export function getDatabase(): any {
if (!db) { if (!db) {
// Create data directory if it doesn't exist
const fs = require("fs");
const dataDir = path.join(process.cwd(), "data"); const dataDir = path.join(process.cwd(), "data");
if (!fs.existsSync(dataDir)) { mkdirSync(dataDir, { recursive: true });
fs.mkdirSync(dataDir, { recursive: true });
}
db = createDatabase(); db = new Database(dbPath, { create: true });
db.exec("PRAGMA journal_mode = WAL"); db.exec("PRAGMA journal_mode = WAL");
initializeDatabase(); initializeDatabase(db);
} }
return db; return db;
} }
function initializeDatabase() { function initializeDatabase(database: Database) {
// Create albums table // Create albums table
db.exec(` database.exec(`
CREATE TABLE IF NOT EXISTS albums ( CREATE TABLE IF NOT EXISTS albums (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
title TEXT NOT NULL, title TEXT NOT NULL,
@@ -52,10 +42,11 @@ function initializeDatabase() {
`); `);
// Create purchases table // Create purchases table
db.exec(` database.exec(`
CREATE TABLE IF NOT EXISTS purchases ( CREATE TABLE IF NOT EXISTS purchases (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
albumId TEXT NOT NULL, albumId TEXT NOT NULL,
albumIds TEXT,
transactionId TEXT NOT NULL UNIQUE, transactionId TEXT NOT NULL UNIQUE,
customerName TEXT, customerName TEXT,
email TEXT, email TEXT,
@@ -64,13 +55,19 @@ function initializeDatabase() {
purchaseDate INTEGER NOT NULL, purchaseDate INTEGER NOT NULL,
approvalStatus TEXT DEFAULT 'pending', approvalStatus TEXT DEFAULT 'pending',
paymentMethod TEXT DEFAULT 'card-to-card', paymentMethod TEXT DEFAULT 'card-to-card',
reviewedByTelegramId TEXT,
reviewedAt INTEGER,
telegramUserId TEXT,
telegramChatId TEXT,
receiptType TEXT,
receiptTelegramFileId TEXT,
createdAt INTEGER DEFAULT (strftime('%s', 'now')), createdAt INTEGER DEFAULT (strftime('%s', 'now')),
FOREIGN KEY (albumId) REFERENCES albums(id) ON DELETE CASCADE FOREIGN KEY (albumId) REFERENCES albums(id) ON DELETE CASCADE
) )
`); `);
// Create payment authorities table for ZarinPal tracking // Create payment authorities table for ZarinPal tracking
db.exec(` database.exec(`
CREATE TABLE IF NOT EXISTS payment_authorities ( CREATE TABLE IF NOT EXISTS payment_authorities (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
authority TEXT NOT NULL UNIQUE, authority TEXT NOT NULL UNIQUE,
@@ -89,20 +86,81 @@ function initializeDatabase() {
) )
`); `);
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) // Add columns if they don't exist (migration)
try { try {
db.exec(`ALTER TABLE purchases ADD COLUMN approvalStatus TEXT DEFAULT 'pending'`); database.exec(`ALTER TABLE purchases ADD COLUMN approvalStatus TEXT DEFAULT 'pending'`);
} catch (e) { } catch (e) {
// Column already exists // Column already exists
} }
try { try {
db.exec(`ALTER TABLE purchases ADD COLUMN paymentMethod TEXT DEFAULT 'card-to-card'`); database.exec(`ALTER TABLE purchases ADD COLUMN paymentMethod TEXT DEFAULT 'card-to-card'`);
} catch (e) { } catch (e) {
// Column already exists // 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 // Create indexes
db.exec(` database.exec(`
CREATE INDEX IF NOT EXISTS idx_purchases_albumId ON purchases(albumId); 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_transactionId ON purchases(transactionId);
CREATE INDEX IF NOT EXISTS idx_purchases_approvalStatus ON purchases(approvalStatus); CREATE INDEX IF NOT EXISTS idx_purchases_approvalStatus ON purchases(approvalStatus);
@@ -111,21 +169,21 @@ function initializeDatabase() {
`); `);
// Seed initial data if albums table is empty // Seed initial data if albums table is empty
const count = db.prepare("SELECT COUNT(*) as count FROM albums").get() as { const count = database.prepare("SELECT COUNT(*) as count FROM albums").get() as {
count: number; count: number;
}; };
if (count.count === 0) { if (count.count === 0) {
seedInitialData(); seedInitialData(database);
} }
} }
function seedInitialData() { function seedInitialData(database: Database) {
const insert = db.prepare(` const insert = database.prepare(`
INSERT INTO albums (id, title, coverImage, year, genre, description, price, tag, format, bitrate, songs) INSERT INTO albums (id, title, coverImage, year, genre, description, price, tag, format, bitrate, songs)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`); `);
const insertMany = db.transaction((albums: Album[]) => { const insertMany = database.transaction((albums: Album[]) => {
for (const album of albums) { for (const album of albums) {
insert.run( insert.run(
album.id, album.id,
@@ -228,6 +286,7 @@ export const purchaseDb = {
return rows.map((row: any) => ({ return rows.map((row: any) => ({
id: row.id, id: row.id,
albumId: row.albumId, albumId: row.albumId,
albumIds: row.albumIds ? JSON.parse(row.albumIds) : [row.albumId],
transactionId: row.transactionId, transactionId: row.transactionId,
customerName: row.customerName, customerName: row.customerName,
email: row.email, email: row.email,
@@ -236,28 +295,19 @@ export const purchaseDb = {
purchaseDate: new Date(row.purchaseDate), purchaseDate: new Date(row.purchaseDate),
approvalStatus: row.approvalStatus, approvalStatus: row.approvalStatus,
paymentMethod: row.paymentMethod, 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[] { getByAlbumId(albumId: string): Purchase[] {
const db = getDatabase(); return this.getAll().filter((purchase) =>
const rows = db (purchase.albumIds || [purchase.albumId]).includes(albumId),
.prepare( );
"SELECT * FROM purchases WHERE albumId = ? ORDER BY purchaseDate DESC",
)
.all(albumId);
return rows.map((row: any) => ({
id: row.id,
albumId: 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,
}));
}, },
getByTransactionId(transactionId: string): Purchase | null { getByTransactionId(transactionId: string): Purchase | null {
@@ -269,6 +319,7 @@ export const purchaseDb = {
return { return {
id: row.id, id: row.id,
albumId: row.albumId, albumId: row.albumId,
albumIds: row.albumIds ? JSON.parse(row.albumIds) : [row.albumId],
transactionId: row.transactionId, transactionId: row.transactionId,
customerName: row.customerName, customerName: row.customerName,
email: row.email, email: row.email,
@@ -277,6 +328,37 @@ export const purchaseDb = {
purchaseDate: new Date(row.purchaseDate), purchaseDate: new Date(row.purchaseDate),
approvalStatus: row.approvalStatus, approvalStatus: row.approvalStatus,
paymentMethod: row.paymentMethod, 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,
}; };
}, },
@@ -285,12 +367,17 @@ export const purchaseDb = {
const result = db const result = db
.prepare( .prepare(
` `
INSERT INTO purchases (albumId, transactionId, customerName, email, phoneNumber, txReceipt, purchaseDate, approvalStatus, paymentMethod) INSERT INTO purchases (
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) albumId, albumIds, transactionId, customerName, email, phoneNumber, txReceipt,
purchaseDate, approvalStatus, paymentMethod, telegramUserId, telegramChatId,
receiptType, receiptTelegramFileId
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, `,
) )
.run( .run(
purchase.albumId, purchase.albumId,
JSON.stringify(purchase.albumIds || [purchase.albumId]),
purchase.transactionId, purchase.transactionId,
purchase.customerName || null, purchase.customerName || null,
purchase.email || null, purchase.email || null,
@@ -301,6 +388,10 @@ export const purchaseDb = {
: purchase.purchaseDate, : purchase.purchaseDate,
purchase.approvalStatus || 'pending', purchase.approvalStatus || 'pending',
purchase.paymentMethod || 'card-to-card', purchase.paymentMethod || 'card-to-card',
purchase.telegramUserId || null,
purchase.telegramChatId || null,
purchase.receiptType || null,
purchase.receiptTelegramFileId || null,
); );
return { return {
@@ -313,4 +404,162 @@ export const purchaseDb = {
const db = getDatabase(); const db = getDatabase();
db.prepare("DELETE FROM purchases WHERE id = ?").run(id); 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)!;
},
}; };
+524
View File
@@ -0,0 +1,524 @@
import {
albumDb,
purchaseDb,
telegramApproverDb,
telegramSessionDb,
telegramSettingsDb,
} from "./db";
import { Album, Purchase, PurchaseStatus, TelegramUserSession } from "./types";
import { formatPrice } from "./utils";
interface TelegramResponse<T> {
ok: boolean;
result?: T;
description?: string;
}
interface TelegramUser {
id: number;
first_name?: string;
last_name?: string;
username?: string;
}
interface TelegramMessage {
message_id: number;
chat: { id: number };
from?: TelegramUser;
text?: string;
caption?: string;
photo?: Array<{ file_id: string; width: number; height: number; file_size?: number }>;
}
export interface TelegramUpdate {
message?: TelegramMessage;
callback_query?: {
id: string;
from: TelegramUser;
data?: string;
message?: TelegramMessage;
};
}
function getTelegramConfig(): { baseUrl: string; token: string } {
const settings = telegramSettingsDb.get();
if (!settings.botToken) {
throw new Error("Telegram bot token is not configured in the admin panel");
}
return { baseUrl: settings.baseUrl.replace(/\/$/, ""), token: settings.botToken };
}
export function isTelegramConfigured(): boolean {
return Boolean(telegramSettingsDb.get().botToken);
}
export async function callTelegram<T = unknown>(
method: string,
payload: Record<string, unknown>,
): Promise<T> {
const { baseUrl, token } = getTelegramConfig();
const response = await fetch(`${baseUrl}/bot${token}/${method}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const body = (await response.json()) as TelegramResponse<T>;
if (!response.ok || !body.ok) throw new Error(body.description || `Telegram ${method} failed`);
return body.result as T;
}
async function callTelegramMultipart<T = unknown>(method: string, form: FormData): Promise<T> {
const { baseUrl, token } = getTelegramConfig();
const response = await fetch(`${baseUrl}/bot${token}/${method}`, { method: "POST", body: form });
const body = (await response.json()) as TelegramResponse<T>;
if (!response.ok || !body.ok) throw new Error(body.description || `Telegram ${method} failed`);
return body.result as T;
}
export async function registerTelegramWebhook(appUrl?: string): Promise<void> {
const publicUrl = (appUrl || process.env.NEXT_PUBLIC_APP_URL || "https://podzahr.com").replace(/\/$/, "");
const secret = process.env.TELEGRAM_WEBHOOK_SECRET;
if (!publicUrl.startsWith("https://")) {
throw new Error("NEXT_PUBLIC_APP_URL must be a public HTTPS URL");
}
if (!secret) throw new Error("TELEGRAM_WEBHOOK_SECRET is not configured");
await callTelegram("setWebhook", {
url: `${publicUrl}/api/telegram/webhook`,
secret_token: secret,
allowed_updates: ["message", "callback_query"],
});
}
function albumIdsForPurchase(purchase: Purchase): string[] {
return purchase.albumIds?.length ? purchase.albumIds : [purchase.albumId];
}
function albumsForPurchase(purchase: Purchase): Album[] {
return albumIdsForPurchase(purchase)
.map((id) => albumDb.getById(id))
.filter((album): album is Album => Boolean(album));
}
function totalForAlbums(albums: Album[]): number {
return albums.reduce((total, album) => total + album.price, 0);
}
function displayName(user: TelegramUser): string {
const name = [user.first_name, user.last_name].filter(Boolean).join(" ").trim();
return name || (user.username ? `@${user.username}` : String(user.id));
}
function ensureSession(user: TelegramUser, chatId: number): TelegramUserSession {
const existing = telegramSessionDb.get(String(user.id));
return telegramSessionDb.upsert({
userId: String(user.id),
chatId: String(chatId),
state: existing?.state || "browsing",
cartAlbumIds: existing?.cartAlbumIds || [],
displayName: displayName(user),
});
}
function mainMenuKeyboard() {
return {
inline_keyboard: [[
{ text: "Browse albums", callback_data: "catalog" },
{ text: "My cart", callback_data: "cart" },
]],
};
}
async function showCatalog(chatId: number | string): Promise<void> {
const albums = albumDb.getAll();
if (albums.length === 0) {
await callTelegram("sendMessage", { chat_id: chatId, text: "There are no albums available." });
return;
}
await callTelegram("sendMessage", {
chat_id: chatId,
text: "Choose albums to add to your cart:",
reply_markup: {
inline_keyboard: [
...albums.map((album) => [{
text: `Add ${album.title}${formatPrice(album.price)}`,
callback_data: `cart:add:${album.id}`,
}]),
[{ text: "View cart", callback_data: "cart" }],
],
},
});
}
async function showCart(userId: string, chatId: number | string): Promise<void> {
const session = telegramSessionDb.get(userId);
const albums = (session?.cartAlbumIds || [])
.map((id) => albumDb.getById(id))
.filter((album): album is Album => Boolean(album));
if (albums.length === 0) {
await callTelegram("sendMessage", {
chat_id: chatId,
text: "Your cart is empty.",
reply_markup: { inline_keyboard: [[{ text: "Browse albums", callback_data: "catalog" }]] },
});
return;
}
await callTelegram("sendMessage", {
chat_id: chatId,
text: [
"Your cart:",
...albums.map((album, index) => `${index + 1}. ${album.title}${formatPrice(album.price)}`),
"",
`Total: ${formatPrice(totalForAlbums(albums))}`,
].join("\n"),
reply_markup: {
inline_keyboard: [
...albums.map((album) => [{ text: `Remove ${album.title}`, callback_data: `cart:remove:${album.id}` }]),
[{ text: "Checkout", callback_data: "checkout" }],
[
{ text: "Add more", callback_data: "catalog" },
{ text: "Clear cart", callback_data: "cart:clear" },
],
],
},
});
}
async function beginCheckout(session: TelegramUserSession): Promise<void> {
const settings = telegramSettingsDb.get();
const albums = session.cartAlbumIds
.map((id) => albumDb.getById(id))
.filter((album): album is Album => Boolean(album));
if (albums.length === 0) return showCart(session.userId, session.chatId);
if (!settings.cardNumber || !settings.cardHolderName) {
await callTelegram("sendMessage", {
chat_id: session.chatId,
text: "Card payment is not configured yet. Please try again later.",
});
return;
}
telegramSessionDb.upsert({ ...session, state: "awaiting_receipt" });
await callTelegram("sendMessage", {
chat_id: session.chatId,
text: [
`Total: ${formatPrice(totalForAlbums(albums))}`,
"",
`Card number: ${settings.cardNumber}`,
`Cardholder: ${settings.cardHolderName}`,
"",
"Transfer the amount, then send either the transaction ID as text or a screenshot of the receipt.",
].join("\n"),
});
}
function purchaseMessage(purchase: Purchase): string {
const albums = albumsForPurchase(purchase);
return [
"New Telegram purchase awaiting review",
"",
`Purchase: #${purchase.id}`,
`Albums: ${albums.map((album) => album.title).join(", ") || purchase.albumId}`,
`Amount: ${formatPrice(totalForAlbums(albums))}`,
`Customer: ${purchase.customerName || "N/A"}`,
`Telegram user: ${purchase.telegramUserId || "N/A"}`,
`Transaction: ${purchase.transactionId}`,
`Receipt: ${purchase.txReceipt || purchase.receiptType || "N/A"}`,
].join("\n");
}
function reviewKeyboard(purchaseId: number) {
return {
inline_keyboard: [[
{ text: "Approve", callback_data: `purchase:approved:${purchaseId}` },
{ text: "Reject", callback_data: `purchase:rejected:${purchaseId}` },
]],
};
}
export async function sendPurchaseForReview(
chatId: string | number,
purchase: Purchase,
): Promise<void> {
if (!purchase.id) return;
const payload = {
chat_id: chatId,
reply_markup: reviewKeyboard(purchase.id),
};
if (purchase.receiptType === "photo" && purchase.receiptTelegramFileId) {
await callTelegram("sendPhoto", {
...payload,
photo: purchase.receiptTelegramFileId,
caption: purchaseMessage(purchase),
});
return;
}
await callTelegram("sendMessage", { ...payload, text: purchaseMessage(purchase) });
}
export async function notifyPurchaseApprovers(purchase: Purchase): Promise<void> {
if (!isTelegramConfigured() || purchase.approvalStatus !== "pending") return;
const results = await Promise.allSettled(
telegramApproverDb.getAll().map(({ userId }) => sendPurchaseForReview(userId, purchase)),
);
for (const result of results) {
if (result.status === "rejected") {
console.error("Telegram purchase notification failed:", result.reason);
}
}
}
async function saveReceipt(message: TelegramMessage, session: TelegramUserSession): Promise<void> {
const albums = session.cartAlbumIds
.map((id) => albumDb.getById(id))
.filter((album): album is Album => Boolean(album));
if (albums.length === 0) {
telegramSessionDb.upsert({ ...session, state: "browsing", cartAlbumIds: [] });
await showCart(session.userId, session.chatId);
return;
}
const textReceipt = message.text?.trim();
const photo = message.photo?.at(-1);
if (!textReceipt && !photo) {
await callTelegram("sendMessage", {
chat_id: session.chatId,
text: "Please send the transaction ID as text or upload a receipt screenshot.",
});
return;
}
const purchase = purchaseDb.create({
albumId: albums[0].id,
albumIds: albums.map((album) => album.id),
transactionId: `TG-${session.userId}-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`,
customerName: session.displayName,
txReceipt: textReceipt || message.caption?.trim() || "Telegram receipt screenshot",
purchaseDate: Date.now(),
approvalStatus: "pending",
paymentMethod: "card-to-card",
telegramUserId: session.userId,
telegramChatId: session.chatId,
receiptType: photo ? "photo" : "text",
receiptTelegramFileId: photo?.file_id,
});
telegramSessionDb.upsert({ ...session, state: "browsing", cartAlbumIds: [] });
await callTelegram("sendMessage", {
chat_id: session.chatId,
text: `Receipt received for purchase #${purchase.id}. You will be notified after review.`,
reply_markup: mainMenuKeyboard(),
});
await notifyPurchaseApprovers(purchase);
}
function resolveFileUrl(url: string): string {
if (/^https?:\/\//i.test(url)) return url;
const appUrl = process.env.NEXT_PUBLIC_APP_URL || "https://podzahr.com";
return new URL(url, appUrl).toString();
}
function safeFileName(value: string): string {
return value.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "track";
}
async function uploadSong(chatId: string, album: Album, song: Album["songs"][number]): Promise<void> {
const response = await fetch(resolveFileUrl(song.fullUrl));
if (!response.ok) throw new Error(`Could not download ${album.title} / ${song.title}`);
const blob = await response.blob();
const extension = album.format || "mp3";
const form = new FormData();
form.set("chat_id", chatId);
form.set("caption", `${album.title}${song.title}`);
form.set("document", blob, `${safeFileName(album.title)}-${safeFileName(song.title)}.${extension}`);
await callTelegramMultipart("sendDocument", form);
}
export async function notifyCustomerOfDecision(purchase: Purchase): Promise<void> {
if (!purchase.telegramChatId) return;
if (purchase.approvalStatus === "rejected") {
await callTelegram("sendMessage", {
chat_id: purchase.telegramChatId,
text: `Your purchase #${purchase.id} was rejected. Please contact support if you believe this is a mistake.`,
reply_markup: mainMenuKeyboard(),
});
return;
}
if (purchase.approvalStatus !== "approved") return;
await callTelegram("sendMessage", {
chat_id: purchase.telegramChatId,
text: `Congratulations! Purchase #${purchase.id} was approved. Your album files are being uploaded now.`,
});
for (const album of albumsForPurchase(purchase)) {
for (const song of album.songs) {
try {
await uploadSong(purchase.telegramChatId, album, song);
} catch (error) {
console.error("Telegram album delivery failed:", error);
await callTelegram("sendMessage", {
chat_id: purchase.telegramChatId,
text: `Could not upload ${album.title}${song.title}. Please contact support.`,
});
}
}
}
await callTelegram("sendMessage", {
chat_id: purchase.telegramChatId,
text: "All available album files have been delivered. Thank you!",
reply_markup: mainMenuKeyboard(),
});
}
export async function handleTelegramUpdate(update: TelegramUpdate): Promise<void> {
if (update.callback_query) {
await handleCallback(update.callback_query);
return;
}
const message = update.message;
if (!message?.from) return;
const session = ensureSession(message.from, message.chat.id);
const command = message.text?.startsWith("/")
? message.text.split(/\s+/)[0].split("@")[0]
: undefined;
if (command === "/start") {
const settings = telegramSettingsDb.get();
telegramSessionDb.upsert({ ...session, state: "browsing" });
await callTelegram("sendMessage", {
chat_id: message.chat.id,
text: settings.greetingText,
reply_markup: mainMenuKeyboard(),
});
await showCatalog(message.chat.id);
return;
}
if (command === "/id") {
const authorized = telegramApproverDb.isAuthorized(session.userId);
await callTelegram("sendMessage", {
chat_id: message.chat.id,
text: `Your Telegram user ID is ${session.userId}.\nApproval access: ${authorized ? "enabled" : "not enabled"}.`,
});
return;
}
if (command === "/pending") {
if (!telegramApproverDb.isAuthorized(session.userId)) {
await callTelegram("sendMessage", { chat_id: message.chat.id, text: "You are not authorized." });
return;
}
const pending = purchaseDb.getAll().filter((purchase) => purchase.approvalStatus === "pending");
if (pending.length === 0) {
await callTelegram("sendMessage", { chat_id: message.chat.id, text: "No pending purchases." });
} else {
for (const purchase of pending.slice(0, 20)) await sendPurchaseForReview(message.chat.id, purchase);
}
return;
}
if (command === "/albums") return showCatalog(message.chat.id);
if (command === "/cart") return showCart(session.userId, message.chat.id);
if (session.state === "awaiting_receipt") return saveReceipt(message, session);
await callTelegram("sendMessage", {
chat_id: message.chat.id,
text: "Use the buttons below to browse albums or view your cart.",
reply_markup: mainMenuKeyboard(),
});
}
async function handleCustomerCallback(
callback: NonNullable<TelegramUpdate["callback_query"]>,
): Promise<void> {
const chatId = callback.message?.chat.id;
if (!chatId) return;
const session = ensureSession(callback.from, chatId);
const data = callback.data || "";
await callTelegram("answerCallbackQuery", { callback_query_id: callback.id });
if (data === "catalog") return showCatalog(chatId);
if (data === "cart") return showCart(session.userId, chatId);
if (data === "checkout") return beginCheckout(session);
if (data === "cart:clear") {
telegramSessionDb.upsert({ ...session, state: "browsing", cartAlbumIds: [] });
return showCart(session.userId, chatId);
}
const cartAction = data.match(/^cart:(add|remove):(.+)$/);
if (!cartAction) return;
const album = albumDb.getById(cartAction[2]);
if (!album) {
await callTelegram("sendMessage", { chat_id: chatId, text: "That album is no longer available." });
return;
}
const cart = new Set(session.cartAlbumIds);
if (cartAction[1] === "add") cart.add(album.id);
else cart.delete(album.id);
telegramSessionDb.upsert({ ...session, state: "browsing", cartAlbumIds: [...cart] });
await callTelegram("sendMessage", {
chat_id: chatId,
text: cartAction[1] === "add" ? `${album.title} added to your cart.` : `${album.title} removed.`,
reply_markup: mainMenuKeyboard(),
});
}
async function handleCallback(callback: NonNullable<TelegramUpdate["callback_query"]>) {
const match = callback.data?.match(/^purchase:(approved|rejected):(\d+)$/);
if (!match) return handleCustomerCallback(callback);
const userId = String(callback.from.id);
if (!telegramApproverDb.isAuthorized(userId)) {
await callTelegram("answerCallbackQuery", {
callback_query_id: callback.id,
text: "You are not authorized to review purchases.",
show_alert: true,
});
return;
}
const status = match[1] as PurchaseStatus;
const purchaseId = Number(match[2]);
const existing = purchaseDb.getById(purchaseId);
if (!existing || existing.approvalStatus !== "pending") {
await callTelegram("answerCallbackQuery", {
callback_query_id: callback.id,
text: existing ? `Already ${existing.approvalStatus}.` : "Purchase not found.",
show_alert: true,
});
return;
}
const updated = purchaseDb.setStatus(purchaseId, status, userId);
if (!updated) {
await callTelegram("answerCallbackQuery", {
callback_query_id: callback.id,
text: "This purchase was reviewed by someone else.",
show_alert: true,
});
return;
}
await callTelegram("answerCallbackQuery", {
callback_query_id: callback.id,
text: `Purchase ${status}.`,
});
if (callback.message) {
await callTelegram("editMessageReplyMarkup", {
chat_id: callback.message.chat.id,
message_id: callback.message.message_id,
reply_markup: { inline_keyboard: [] },
});
await callTelegram("sendMessage", {
chat_id: callback.message.chat.id,
text: `Purchase #${purchaseId} was ${status} by Telegram user ${userId}.`,
});
}
void notifyCustomerOfDecision(updated).catch((error) => {
console.error("Telegram customer notification failed:", error);
});
}
+33
View File
@@ -29,6 +29,7 @@ export type PaymentMethod = 'ipg' | 'card-to-card';
export interface Purchase { export interface Purchase {
id?: number; id?: number;
albumId: string; albumId: string;
albumIds?: string[];
transactionId: string; transactionId: string;
customerName?: string; customerName?: string;
email?: string; email?: string;
@@ -37,4 +38,36 @@ export interface Purchase {
purchaseDate: Date | number; purchaseDate: Date | number;
approvalStatus?: PurchaseStatus; // pending, approved, rejected approvalStatus?: PurchaseStatus; // pending, approved, rejected
paymentMethod?: PaymentMethod; // ipg or card-to-card paymentMethod?: PaymentMethod; // ipg or card-to-card
reviewedByTelegramId?: string;
reviewedAt?: Date | number;
telegramUserId?: string;
telegramChatId?: string;
receiptType?: 'text' | 'photo';
receiptTelegramFileId?: string;
}
export interface TelegramApprover {
userId: string;
label?: string;
createdAt?: Date | number;
}
export interface TelegramBotSettings {
baseUrl: string;
botTokenConfigured: boolean;
greetingText: string;
cardNumber: string;
cardHolderName: string;
updatedAt?: Date | number;
}
export type TelegramSessionState = 'browsing' | 'awaiting_receipt';
export interface TelegramUserSession {
userId: string;
chatId: string;
state: TelegramSessionState;
cartAlbumIds: string[];
displayName?: string;
updatedAt: Date | number;
} }
+6 -5
View File
@@ -3,11 +3,12 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "bun --bun next dev",
"build": "next build", "build": "bun --bun next build",
"start": "next start", "start": "bun --bun next start",
"lint": "next lint", "lint": "bun --bun next lint",
"seed": "bun run scripts/seed.ts" "seed": "bun run scripts/seed.ts",
"telegram:webhook": "bun run scripts/setup-telegram-webhook.ts"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.937.0", "@aws-sdk/client-s3": "^3.937.0",
-265
View File
@@ -1,265 +0,0 @@
const Database = require('better-sqlite3');
const path = require('path');
const fs = require('fs');
// Demo albums data
const albums = [
{
id: "echoes-of-time",
title: "Echoes of Time",
coverImage: "/albums/echoes-of-time.jpg",
year: 2024,
genre: "Progressive Rock",
price: 350000,
tag: "Album",
format: "flac",
bitrate: "lossless",
description: "An epic journey through time and space, featuring complex polyrhythms, atmospheric keyboards, and powerful guitar solos. This concept album tells the story of humanity's relationship with time.",
songs: [
{
id: "echoes-1",
title: "Temporal Flux",
duration: "8:45",
previewUrl: "/audio/preview-1.mp3",
fullUrl: "/audio/echoes-1-full.mp3"
},
{
id: "echoes-2",
title: "Clockwork Dreams",
duration: "6:23",
previewUrl: "/audio/preview-1.mp3",
fullUrl: "/audio/echoes-2-full.mp3"
},
{
id: "echoes-3",
title: "The Eternal Now",
duration: "12:17",
previewUrl: "/audio/preview-1.mp3",
fullUrl: "/audio/echoes-3-full.mp3"
},
{
id: "echoes-4",
title: "Yesterday's Tomorrow",
duration: "7:56",
previewUrl: "/audio/preview-1.mp3",
fullUrl: "/audio/echoes-4-full.mp3"
},
{
id: "echoes-5",
title: "Echoes Fade",
duration: "15:32",
previewUrl: "/audio/preview-1.mp3",
fullUrl: "/audio/echoes-5-full.mp3"
}
]
},
{
id: "crimson-horizons",
title: "Crimson Horizons",
coverImage: "/albums/crimson-horizons.jpg",
year: 2023,
genre: "Progressive Rock",
price: 10.99,
tag: "Deluxe",
format: "mp3",
bitrate: "320kbps",
description: "A darker, heavier exploration of prog rock with crushing riffs and intricate instrumental passages. Features extended improvisational sections and powerful vocals.",
songs: [
{
id: "crimson-1",
title: "Red Dawn",
duration: "9:12",
previewUrl: "/audio/preview-1.mp3",
fullUrl: "/audio/crimson-1-full.mp3"
},
{
id: "crimson-2",
title: "Horizon's Edge",
duration: "7:45",
previewUrl: "/audio/preview-1.mp3",
fullUrl: "/audio/crimson-2-full.mp3"
},
{
id: "crimson-3",
title: "Scarlet Skies",
duration: "11:03",
previewUrl: "/audio/preview-1.mp3",
fullUrl: "/audio/crimson-3-full.mp3"
},
{
id: "crimson-4",
title: "Blood Moon Rising",
duration: "8:34",
previewUrl: "/audio/preview-1.mp3",
fullUrl: "/audio/crimson-4-full.mp3"
},
{
id: "crimson-5",
title: "Into the Void",
duration: "13:21",
previewUrl: "/audio/preview-1.mp3",
fullUrl: "/audio/crimson-5-full.mp3"
}
]
},
{
id: "cosmic-resonance",
title: "Cosmic Resonance",
coverImage: "/albums/cosmic-resonance.jpg",
year: 2022,
genre: "Space Rock",
price: 11.99,
tag: "EP",
format: "wav",
bitrate: "lossless",
description: "A space-themed odyssey combining ambient soundscapes with progressive rock energy. Synthesizers and guitars intertwine to create an otherworldly sonic experience.",
songs: [
{
id: "cosmic-1",
title: "Stellar Winds",
duration: "10:24",
previewUrl: "/audio/preview-1.mp3",
fullUrl: "/audio/cosmic-1-full.mp3"
},
{
id: "cosmic-2",
title: "Nebula Dreams",
duration: "8:17",
previewUrl: "/audio/preview-1.mp3",
fullUrl: "/audio/cosmic-2-full.mp3"
},
{
id: "cosmic-3",
title: "Gravity Wells",
duration: "9:45",
previewUrl: "/audio/preview-1.mp3",
fullUrl: "/audio/cosmic-3-full.mp3"
},
{
id: "cosmic-4",
title: "Cosmic Dance",
duration: "11:56",
previewUrl: "/audio/preview-1.mp3",
fullUrl: "/audio/cosmic-4-full.mp3"
}
]
}
];
function seedDatabase() {
try {
// Database path
const dbPath = path.join(process.cwd(), 'data', 'parsa.db');
// Create data directory if it doesn't exist
const dataDir = path.join(process.cwd(), 'data');
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
console.log('✓ Created data directory');
}
// Initialize database
const db = new Database(dbPath);
db.pragma('journal_mode = WAL');
console.log('✓ Connected to database');
// Create albums table
db.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'))
)
`);
console.log('✓ Created albums table');
// Create purchases table
db.exec(`
CREATE TABLE IF NOT EXISTS purchases (
id INTEGER PRIMARY KEY AUTOINCREMENT,
albumId TEXT NOT NULL,
transactionId TEXT NOT NULL UNIQUE,
customerName TEXT,
email TEXT,
phoneNumber TEXT,
txReceipt TEXT,
purchaseDate INTEGER NOT NULL,
createdAt INTEGER DEFAULT (strftime('%s', 'now')),
FOREIGN KEY (albumId) REFERENCES albums(id) ON DELETE CASCADE
)
`);
console.log('✓ Created purchases table');
// Create indexes
db.exec(`
CREATE INDEX IF NOT EXISTS idx_purchases_albumId ON purchases(albumId);
CREATE INDEX IF NOT EXISTS idx_purchases_transactionId ON purchases(transactionId);
`);
console.log('✓ Created indexes');
// Clear existing albums
db.exec('DELETE FROM albums');
console.log('✓ Cleared existing albums');
// Insert demo albums
const insert = db.prepare(`
INSERT INTO albums (id, title, coverImage, year, genre, description, price, tag, format, bitrate, songs)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const insertMany = db.transaction((albums) => {
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(albums);
console.log(`✓ Inserted ${albums.length} demo albums`);
// Verify
const count = db.prepare('SELECT COUNT(*) as count FROM albums').get();
console.log(`✓ Database now contains ${count.count} albums`);
db.close();
console.log('\n✅ Database seeded successfully!');
console.log('\nYou can now run: pnpm dev');
} catch (error) {
console.error('\n❌ Error seeding database:', error.message);
console.error('\nTrying to rebuild better-sqlite3...');
const { execSync } = require('child_process');
try {
execSync('npm rebuild better-sqlite3', { stdio: 'inherit' });
console.log('\n✓ Rebuilt better-sqlite3, please run the seed script again: pnpm seed');
} catch (rebuildError) {
console.error('❌ Failed to rebuild better-sqlite3');
console.error('Please try manually: npm rebuild better-sqlite3');
}
process.exit(1);
}
}
seedDatabase();
+12
View File
@@ -0,0 +1,12 @@
export {};
import { registerTelegramWebhook } from "../lib/telegram";
try {
await registerTelegramWebhook();
const appUrl = process.env.NEXT_PUBLIC_APP_URL || "https://podzahr.com";
console.log(`${appUrl}/api/telegram/webhook registered with Telegram`);
} catch (error) {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
}