feat: add Telegram album purchase bot
This commit is contained in:
@@ -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_MERCHANT_ID=your-merchant-id-here
|
||||
|
||||
|
||||
+25
-42
@@ -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:1-alpine AS deps
|
||||
FROM oven/bun:${BUN_VERSION}-alpine AS base
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package.json bun.lockb* ./
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# Install dependencies with Bun
|
||||
FROM base AS dependencies
|
||||
|
||||
COPY package.json bun.lock ./
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
# Stage 2: Builder
|
||||
FROM oven/bun:1-alpine AS builder
|
||||
WORKDIR /app
|
||||
FROM base AS builder
|
||||
|
||||
# Copy dependencies from deps stage
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
ENV NODE_ENV=production
|
||||
|
||||
COPY --from=dependencies /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# Set environment variables for build
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV NODE_ENV=production
|
||||
# package.json forces the Next.js CLI to execute with Bun.
|
||||
RUN bun run build
|
||||
|
||||
# Build the application with --bun flag to use Bun runtime
|
||||
RUN bun --bun run build
|
||||
FROM base AS runner
|
||||
|
||||
# Stage 3: Runner
|
||||
FROM oven/bun:1-alpine AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production \
|
||||
HOSTNAME=0.0.0.0 \
|
||||
PORT=3000
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
COPY --from=builder --chown=bun:bun /app/public ./public
|
||||
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
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
# bun:sqlite writes the database and WAL files here.
|
||||
RUN mkdir -p /app/data && chown -R bun:bun /app/data
|
||||
|
||||
# Copy necessary files from builder
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
USER bun
|
||||
|
||||
# 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
|
||||
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||
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", "--bun", "run", "server.js"]
|
||||
CMD ["bun", "server.js"]
|
||||
|
||||
@@ -45,8 +45,7 @@ A modern, interactive website showcasing a progressive rock composer and produce
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 18+
|
||||
- npm or yarn
|
||||
- Bun 1.3+
|
||||
|
||||
### Installation
|
||||
|
||||
@@ -54,13 +53,13 @@ A modern, interactive website showcasing a progressive rock composer and produce
|
||||
2. Install dependencies:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
bun install
|
||||
```
|
||||
|
||||
3. Run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
bun run dev
|
||||
```
|
||||
|
||||
4. Open [http://localhost:3000](http://localhost:3000) in your browser
|
||||
@@ -197,13 +196,33 @@ export const albums: Album[] = [
|
||||
## Build for Production
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm start
|
||||
bun run build
|
||||
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
|
||||
|
||||
- **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
|
||||
- **Tailwind CSS**: Utility-first CSS framework
|
||||
- **Framer Motion**: Animation library
|
||||
|
||||
@@ -10,7 +10,6 @@ import AdminLayout from '@/components/AdminLayout';
|
||||
|
||||
export default function AdminDashboard() {
|
||||
const { albums } = useAlbums();
|
||||
const [purchases, setPurchases] = useState<Purchase[]>([]);
|
||||
const [stats, setStats] = useState({
|
||||
totalAlbums: 0,
|
||||
totalPurchases: 0,
|
||||
@@ -19,33 +18,27 @@ export default function AdminDashboard() {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Load purchases from localStorage
|
||||
const savedPurchases = localStorage.getItem('purchases');
|
||||
if (savedPurchases) {
|
||||
const parsedPurchases = JSON.parse(savedPurchases);
|
||||
setPurchases(parsedPurchases);
|
||||
|
||||
// Calculate stats
|
||||
const totalRevenue = parsedPurchases.reduce((total: number, purchase: Purchase) => {
|
||||
const album = albums.find((a) => a.id === purchase.albumId);
|
||||
return total + (album?.price || 0);
|
||||
const loadStats = async () => {
|
||||
const response = await fetch('/api/purchases');
|
||||
if (!response.ok) return;
|
||||
const allPurchases = await response.json() as Purchase[];
|
||||
const totalRevenue = allPurchases
|
||||
.filter((purchase) => purchase.approvalStatus === 'approved')
|
||||
.reduce((total, purchase) => {
|
||||
const ids = purchase.albumIds || [purchase.albumId];
|
||||
return total + albums
|
||||
.filter((album) => ids.includes(album.id))
|
||||
.reduce((subtotal, album) => subtotal + album.price, 0);
|
||||
}, 0);
|
||||
|
||||
setStats({
|
||||
totalAlbums: albums.length,
|
||||
totalPurchases: parsedPurchases.length,
|
||||
totalPurchases: allPurchases.length,
|
||||
totalRevenue,
|
||||
recentPurchases: parsedPurchases.slice(-5).reverse(),
|
||||
recentPurchases: allPurchases.slice(0, 5),
|
||||
});
|
||||
} else {
|
||||
setStats({
|
||||
totalAlbums: albums.length,
|
||||
totalPurchases: 0,
|
||||
totalRevenue: 0,
|
||||
recentPurchases: [],
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
};
|
||||
void loadStats();
|
||||
}, [albums]);
|
||||
|
||||
const statCards = [
|
||||
{
|
||||
@@ -124,21 +117,26 @@ export default function AdminDashboard() {
|
||||
{stats.recentPurchases.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{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 (
|
||||
<div
|
||||
key={purchase.transactionId}
|
||||
className="flex items-center justify-between p-4 bg-paper-light border-2 border-paper-brown/30"
|
||||
>
|
||||
<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">
|
||||
{new Date(purchase.purchaseDate).toLocaleDateString()} at{' '}
|
||||
{new Date(purchase.purchaseDate).toLocaleTimeString()}
|
||||
</p>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+249
-15
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
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 { Purchase } from '@/lib/types';
|
||||
import { formatPrice } from '@/lib/utils';
|
||||
@@ -14,9 +14,20 @@ export default function AdminPurchasesPage() {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [filterStatus, setFilterStatus] = useState<'all' | 'pending' | 'approved' | 'rejected'>('all');
|
||||
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 () => {
|
||||
setLoading(true);
|
||||
const fetchPurchases = async (showLoading = true) => {
|
||||
if (showLoading) setLoading(true);
|
||||
try {
|
||||
const response = await fetch('/api/purchases');
|
||||
if (response.ok) {
|
||||
@@ -26,14 +37,93 @@ export default function AdminPurchasesPage() {
|
||||
} catch (error) {
|
||||
console.error('Error fetching purchases:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (showLoading) setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
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) => {
|
||||
try {
|
||||
const response = await fetch(`/api/purchases/${purchaseId}/approve`, {
|
||||
@@ -63,9 +153,10 @@ export default function AdminPurchasesPage() {
|
||||
};
|
||||
|
||||
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 =
|
||||
album?.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
purchaseAlbums.some((album) => album.title.toLowerCase().includes(searchTerm.toLowerCase())) ||
|
||||
purchase.transactionId.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
purchase.customerName?.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 totalRevenue = approvedPurchases.reduce((total, purchase) => {
|
||||
const album = albums.find((a) => a.id === purchase.albumId);
|
||||
return total + (album?.price || 0);
|
||||
const purchaseAlbumIds = purchase.albumIds || [purchase.albumId];
|
||||
return total + albums
|
||||
.filter((album) => purchaseAlbumIds.includes(album.id))
|
||||
.reduce((subtotal, album) => subtotal + album.price, 0);
|
||||
}, 0);
|
||||
|
||||
const exportToCSV = () => {
|
||||
const headers = ['Date', 'Time', 'Transaction ID', 'Album', 'Price', 'Customer', 'Email', 'Phone', 'Status', 'Payment Method'];
|
||||
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);
|
||||
return [
|
||||
date.toLocaleDateString(),
|
||||
date.toLocaleTimeString(),
|
||||
purchase.transactionId,
|
||||
album?.title || 'Unknown',
|
||||
album?.price || 0,
|
||||
purchaseAlbums.map((album) => album.title).join(' + ') || 'Unknown',
|
||||
purchaseAlbums.reduce((total, album) => total + album.price, 0),
|
||||
purchase.customerName || '',
|
||||
purchase.email || '',
|
||||
purchase.phoneNumber || '',
|
||||
@@ -161,6 +255,133 @@ export default function AdminPurchasesPage() {
|
||||
</motion.button>
|
||||
</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 */}
|
||||
<div className="mb-6 space-y-4">
|
||||
<div className="flex gap-2">
|
||||
@@ -218,7 +439,8 @@ export default function AdminPurchasesPage() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{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);
|
||||
|
||||
return (
|
||||
@@ -249,8 +471,13 @@ export default function AdminPurchasesPage() {
|
||||
<div className="text-xs text-paper-gray">{purchase.phoneNumber}</div>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<div className="text-paper-dark font-medium">{album?.title || 'Unknown'}</div>
|
||||
<div className="text-xs text-paper-gray">{album?.songs.length} tracks</div>
|
||||
<div className="text-paper-dark font-medium">
|
||||
{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 className="p-4 text-center">
|
||||
<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 className="p-4 text-center">
|
||||
{getStatusBadge(purchase.approvalStatus)}
|
||||
{purchase.reviewedByTelegramId && (
|
||||
<div className="text-xs text-paper-gray mt-1">
|
||||
Telegram: {purchase.reviewedByTelegramId}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<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 className="p-4">
|
||||
{purchase.approvalStatus === 'pending' && (
|
||||
|
||||
@@ -47,7 +47,7 @@ export default function AlbumDetailPage() {
|
||||
const apiPurchases = await response.json();
|
||||
// Only include approved purchases for determining access
|
||||
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) {
|
||||
console.error('Error fetching purchases:', error);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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(
|
||||
request: NextRequest,
|
||||
@@ -16,25 +17,32 @@ export async function PATCH(
|
||||
);
|
||||
}
|
||||
|
||||
const db = getDatabase();
|
||||
|
||||
// Update purchase status to approved
|
||||
const result = db.prepare(`
|
||||
UPDATE purchases
|
||||
SET approvalStatus = 'approved'
|
||||
WHERE id = ?
|
||||
`).run(purchaseId);
|
||||
|
||||
if (result.changes === 0) {
|
||||
const existing = purchaseDb.getById(purchaseId);
|
||||
if (!existing) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Purchase not found' },
|
||||
{ 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({
|
||||
success: true,
|
||||
message: 'Purchase approved successfully'
|
||||
message: 'Purchase approved successfully',
|
||||
purchase,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Error approving purchase:', error);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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(
|
||||
request: NextRequest,
|
||||
@@ -16,25 +17,32 @@ export async function PATCH(
|
||||
);
|
||||
}
|
||||
|
||||
const db = getDatabase();
|
||||
|
||||
// Update purchase status to rejected
|
||||
const result = db.prepare(`
|
||||
UPDATE purchases
|
||||
SET approvalStatus = 'rejected'
|
||||
WHERE id = ?
|
||||
`).run(purchaseId);
|
||||
|
||||
if (result.changes === 0) {
|
||||
const existing = purchaseDb.getById(purchaseId);
|
||||
if (!existing) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Purchase not found' },
|
||||
{ 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({
|
||||
success: true,
|
||||
message: 'Purchase rejected successfully'
|
||||
message: 'Purchase rejected successfully',
|
||||
purchase,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Error rejecting purchase:', error);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { purchaseDb } from '@/lib/db';
|
||||
import { Purchase } from '@/lib/types';
|
||||
import { notifyPurchaseApprovers } from '@/lib/telegram';
|
||||
|
||||
// GET - Get all purchases
|
||||
export async function GET(request: NextRequest) {
|
||||
@@ -54,6 +55,10 @@ export async function POST(request: NextRequest) {
|
||||
};
|
||||
|
||||
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 });
|
||||
} catch (error) {
|
||||
console.error('Error creating purchase:', error);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
@layer base {
|
||||
body {
|
||||
@apply bg-paper-light text-paper-dark min-h-screen;
|
||||
font-family: Vazirmatn, Tahoma, Arial, sans-serif;
|
||||
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");
|
||||
}
|
||||
|
||||
+1
-7
@@ -1,15 +1,9 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { Vazirmatn } from 'next/font/google';
|
||||
import './globals.css';
|
||||
import { CartProvider } from '@/lib/CartContext';
|
||||
import { AdminProvider } from '@/lib/AdminContext';
|
||||
import { AlbumsProvider } from '@/lib/AlbumsContext';
|
||||
|
||||
const vazirmatn = Vazirmatn({
|
||||
subsets: ['latin', 'arabic'],
|
||||
display: 'swap',
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Podzahr - Parsa Sadatie Music',
|
||||
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 (
|
||||
<html lang="en">
|
||||
<body className={vazirmatn.className}>
|
||||
<body>
|
||||
<AdminProvider>
|
||||
<AlbumsProvider>
|
||||
<CartProvider>{children}</CartProvider>
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ export default function Home() {
|
||||
const apiPurchases = await response.json();
|
||||
// Only include approved purchases for determining access
|
||||
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) {
|
||||
console.error('Error fetching purchases:', error);
|
||||
|
||||
+15
-16
@@ -1,34 +1,33 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: parsa-music-shop
|
||||
init: true
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||
NODE_ENV: production
|
||||
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_REGION=${AWS_REGION:-ir-thr-at1}
|
||||
- AWS_S3_ENDPOINT=${AWS_S3_ENDPOINT}
|
||||
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID}
|
||||
- AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY}
|
||||
- AWS_S3_BUCKET=${AWS_S3_BUCKET}
|
||||
AWS_REGION: ${AWS_REGION:-ir-thr-at1}
|
||||
AWS_S3_ENDPOINT: ${AWS_S3_ENDPOINT:-}
|
||||
AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-}
|
||||
AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-}
|
||||
AWS_S3_BUCKET: ${AWS_S3_BUCKET:-}
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- app-data:/app/data
|
||||
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:
|
||||
- parsa-network
|
||||
|
||||
networks:
|
||||
parsa-network:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
app-data:
|
||||
name: podzahr-data
|
||||
|
||||
@@ -1,39 +1,29 @@
|
||||
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 { Album, Purchase } from "./types";
|
||||
import { Album, Purchase, PurchaseStatus, TelegramApprover, TelegramUserSession } from "./types";
|
||||
|
||||
// Database path
|
||||
const dbPath = path.join(process.cwd(), "data", "parsa.db");
|
||||
|
||||
// Initialize database
|
||||
let db: any;
|
||||
let db: Database | null = null;
|
||||
|
||||
function createDatabase() {
|
||||
// Use Bun's native SQLite
|
||||
const { Database } = require("bun:sqlite");
|
||||
return new Database(dbPath, { create: true });
|
||||
}
|
||||
|
||||
export function getDatabase(): any {
|
||||
export function getDatabase(): Database {
|
||||
if (!db) {
|
||||
// Create data directory if it doesn't exist
|
||||
const fs = require("fs");
|
||||
const dataDir = path.join(process.cwd(), "data");
|
||||
if (!fs.existsSync(dataDir)) {
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
}
|
||||
mkdirSync(dataDir, { recursive: true });
|
||||
|
||||
db = createDatabase();
|
||||
db = new Database(dbPath, { create: true });
|
||||
db.exec("PRAGMA journal_mode = WAL");
|
||||
initializeDatabase();
|
||||
initializeDatabase(db);
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
function initializeDatabase() {
|
||||
function initializeDatabase(database: Database) {
|
||||
// Create albums table
|
||||
db.exec(`
|
||||
database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS albums (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
@@ -52,10 +42,11 @@ function initializeDatabase() {
|
||||
`);
|
||||
|
||||
// Create purchases table
|
||||
db.exec(`
|
||||
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,
|
||||
@@ -64,13 +55,19 @@ function initializeDatabase() {
|
||||
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
|
||||
db.exec(`
|
||||
database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS payment_authorities (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
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)
|
||||
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) {
|
||||
// Column already exists
|
||||
}
|
||||
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) {
|
||||
// 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
|
||||
db.exec(`
|
||||
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);
|
||||
@@ -111,21 +169,21 @@ function initializeDatabase() {
|
||||
`);
|
||||
|
||||
// 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;
|
||||
};
|
||||
if (count.count === 0) {
|
||||
seedInitialData();
|
||||
seedInitialData(database);
|
||||
}
|
||||
}
|
||||
|
||||
function seedInitialData() {
|
||||
const insert = db.prepare(`
|
||||
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 = db.transaction((albums: Album[]) => {
|
||||
const insertMany = database.transaction((albums: Album[]) => {
|
||||
for (const album of albums) {
|
||||
insert.run(
|
||||
album.id,
|
||||
@@ -228,6 +286,7 @@ export const purchaseDb = {
|
||||
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,
|
||||
@@ -236,28 +295,19 @@ export const purchaseDb = {
|
||||
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[] {
|
||||
const db = getDatabase();
|
||||
const rows = db
|
||||
.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,
|
||||
}));
|
||||
return this.getAll().filter((purchase) =>
|
||||
(purchase.albumIds || [purchase.albumId]).includes(albumId),
|
||||
);
|
||||
},
|
||||
|
||||
getByTransactionId(transactionId: string): Purchase | null {
|
||||
@@ -269,6 +319,7 @@ export const purchaseDb = {
|
||||
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,
|
||||
@@ -277,6 +328,37 @@ export const purchaseDb = {
|
||||
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,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -285,12 +367,17 @@ export const purchaseDb = {
|
||||
const result = db
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO purchases (albumId, transactionId, customerName, email, phoneNumber, txReceipt, purchaseDate, approvalStatus, paymentMethod)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
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,
|
||||
@@ -301,6 +388,10 @@ export const purchaseDb = {
|
||||
: purchase.purchaseDate,
|
||||
purchase.approvalStatus || 'pending',
|
||||
purchase.paymentMethod || 'card-to-card',
|
||||
purchase.telegramUserId || null,
|
||||
purchase.telegramChatId || null,
|
||||
purchase.receiptType || null,
|
||||
purchase.receiptTelegramFileId || null,
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -313,4 +404,162 @@ export const purchaseDb = {
|
||||
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)!;
|
||||
},
|
||||
};
|
||||
|
||||
+524
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -29,6 +29,7 @@ export type PaymentMethod = 'ipg' | 'card-to-card';
|
||||
export interface Purchase {
|
||||
id?: number;
|
||||
albumId: string;
|
||||
albumIds?: string[];
|
||||
transactionId: string;
|
||||
customerName?: string;
|
||||
email?: string;
|
||||
@@ -37,4 +38,36 @@ export interface Purchase {
|
||||
purchaseDate: Date | number;
|
||||
approvalStatus?: PurchaseStatus; // pending, approved, rejected
|
||||
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
@@ -3,11 +3,12 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"seed": "bun run scripts/seed.ts"
|
||||
"dev": "bun --bun next dev",
|
||||
"build": "bun --bun next build",
|
||||
"start": "bun --bun next start",
|
||||
"lint": "bun --bun next lint",
|
||||
"seed": "bun run scripts/seed.ts",
|
||||
"telegram:webhook": "bun run scripts/setup-telegram-webhook.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.937.0",
|
||||
|
||||
-265
@@ -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();
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user