feat: scaffold Antonym fashion store
Anonymous Bitcoin-only fashion e-commerce with: - Vue 3 + Tailwind 4 frontend with glassmorphism dark/light design system - Express 5 + SQLite backend with BTCPay Server integration - Nostr identity (NIP-07/keypair) for anonymous purchase tracking - ChaCha20-Poly1305 encrypted shipping addresses - Admin panel with order/product/stock management - SVG logo splash animation with clip-path reveal - 5 seeded products across 4 categories Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
22
server/db/connection.ts
Normal file
22
server/db/connection.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import Database from 'better-sqlite3'
|
||||
import path from 'node:path'
|
||||
import fs from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const DB_PATH = path.join(__dirname, '..', '..', 'data', 'antonym.db')
|
||||
|
||||
let db: Database.Database | null = null
|
||||
|
||||
export function getDb(): Database.Database {
|
||||
if (!db) {
|
||||
const dir = path.dirname(DB_PATH)
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
db = new Database(DB_PATH)
|
||||
db.pragma('journal_mode = WAL')
|
||||
db.pragma('foreign_keys = ON')
|
||||
}
|
||||
return db
|
||||
}
|
||||
53
server/db/schema.ts
Normal file
53
server/db/schema.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type Database from 'better-sqlite3'
|
||||
|
||||
export function initSchema(db: Database.Database): void {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS products (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
price_sats INTEGER NOT NULL,
|
||||
images TEXT NOT NULL DEFAULT '[]',
|
||||
sizes TEXT NOT NULL DEFAULT '[]',
|
||||
category TEXT NOT NULL DEFAULT 'general',
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS orders (
|
||||
id TEXT PRIMARY KEY,
|
||||
nostr_pubkey TEXT,
|
||||
email TEXT,
|
||||
btcpay_invoice_id TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
shipping_address_encrypted TEXT,
|
||||
items TEXT NOT NULL,
|
||||
total_sats INTEGER NOT NULL,
|
||||
note TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS order_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
order_id TEXT NOT NULL REFERENCES orders(id),
|
||||
status TEXT NOT NULL,
|
||||
note TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS admin_sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
expires_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_orders_nostr ON orders(nostr_pubkey);
|
||||
CREATE INDEX IF NOT EXISTS idx_order_events_order ON order_events(order_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_products_slug ON products(slug);
|
||||
CREATE INDEX IF NOT EXISTS idx_products_category ON products(category);
|
||||
`)
|
||||
}
|
||||
19
server/db/seed.ts
Normal file
19
server/db/seed.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type Database from 'better-sqlite3'
|
||||
import { nanoid } from 'nanoid'
|
||||
|
||||
export function seedProducts(db: Database.Database): void {
|
||||
const count = db.prepare('SELECT COUNT(*) as n FROM products').get() as { n: number }
|
||||
if (count.n > 0) return
|
||||
|
||||
const products = [
|
||||
{ id: nanoid(), name: 'Shadow Hoodie', slug: 'shadow-hoodie', description: 'Heavyweight cotton hoodie with embroidered Antonym logo. Oversized fit.', price_sats: 250_000, images: JSON.stringify(['/images/shadow-hoodie.jpg']), sizes: JSON.stringify([{ size: 'S', stock: 10 }, { size: 'M', stock: 15 }, { size: 'L', stock: 12 }, { size: 'XL', stock: 8 }]), category: 'tops' },
|
||||
{ id: nanoid(), name: 'Inverse Tee', slug: 'inverse-tee', description: 'Premium organic cotton t-shirt. Minimalist design with contrast stitching.', price_sats: 85_000, images: JSON.stringify(['/images/inverse-tee.jpg']), sizes: JSON.stringify([{ size: 'S', stock: 20 }, { size: 'M', stock: 25 }, { size: 'L', stock: 18 }, { size: 'XL', stock: 10 }]), category: 'tops' },
|
||||
{ id: nanoid(), name: 'Contradiction Cargo', slug: 'contradiction-cargo', description: 'Relaxed cargo pants with hidden zip pockets. Washed black.', price_sats: 180_000, images: JSON.stringify(['/images/contradiction-cargo.jpg']), sizes: JSON.stringify([{ size: '28', stock: 8 }, { size: '30', stock: 12 }, { size: '32', stock: 15 }, { size: '34', stock: 10 }, { size: '36', stock: 6 }]), category: 'bottoms' },
|
||||
{ id: nanoid(), name: 'Paradox Cap', slug: 'paradox-cap', description: 'Unstructured six-panel cap with tonal embroidery. One size.', price_sats: 45_000, images: JSON.stringify(['/images/paradox-cap.jpg']), sizes: JSON.stringify([{ size: 'ONE SIZE', stock: 30 }]), category: 'accessories' },
|
||||
{ id: nanoid(), name: 'Duality Jacket', slug: 'duality-jacket', description: 'Reversible bomber jacket. Matte black one side, reflective silver the other.', price_sats: 450_000, images: JSON.stringify(['/images/duality-jacket.jpg']), sizes: JSON.stringify([{ size: 'S', stock: 5 }, { size: 'M', stock: 8 }, { size: 'L', stock: 6 }, { size: 'XL', stock: 4 }]), category: 'outerwear' },
|
||||
]
|
||||
|
||||
const insert = db.prepare('INSERT INTO products (id, name, slug, description, price_sats, images, sizes, category) VALUES (@id, @name, @slug, @description, @price_sats, @images, @sizes, @category)')
|
||||
const tx = db.transaction(() => { for (const p of products) insert.run(p) })
|
||||
tx()
|
||||
}
|
||||
36
server/index.ts
Normal file
36
server/index.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import express from 'express'
|
||||
import helmet from 'helmet'
|
||||
import cors from 'cors'
|
||||
import cookieParser from 'cookie-parser'
|
||||
import { getDb } from './db/connection.js'
|
||||
import { initSchema } from './db/schema.js'
|
||||
import { seedProducts } from './db/seed.js'
|
||||
import { productsRouter } from './routes/products.js'
|
||||
import { ordersRouter } from './routes/orders.js'
|
||||
import { webhooksRouter } from './routes/webhooks.js'
|
||||
import { adminRouter } from './routes/admin.js'
|
||||
import { adminProductsRouter } from './routes/adminProducts.js'
|
||||
import { adminOrdersRouter } from './routes/adminOrders.js'
|
||||
|
||||
const app = express()
|
||||
const port = Number(process.env.PORT) || 3141
|
||||
|
||||
app.use(helmet({ contentSecurityPolicy: false }))
|
||||
app.use(cors({ origin: true, credentials: true }))
|
||||
app.use(cookieParser())
|
||||
app.use(express.json({ limit: '1mb' }))
|
||||
|
||||
const db = getDb()
|
||||
initSchema(db)
|
||||
seedProducts(db)
|
||||
|
||||
app.use('/api/products', productsRouter)
|
||||
app.use('/api/orders', ordersRouter)
|
||||
app.use('/api/webhooks', webhooksRouter)
|
||||
app.use('/api/admin', adminRouter)
|
||||
app.use('/api/admin/products', adminProductsRouter)
|
||||
app.use('/api/admin/orders', adminOrdersRouter)
|
||||
|
||||
app.get('/api/health', (_req, res) => { res.json({ ok: true, timestamp: new Date().toISOString() }) })
|
||||
|
||||
app.listen(port, () => { console.log(`Antonym API running on http://localhost:${port}`) })
|
||||
50
server/lib/btcpay.ts
Normal file
50
server/lib/btcpay.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import crypto from 'node:crypto'
|
||||
|
||||
const TIMEOUT = 10_000
|
||||
|
||||
function getConfig() {
|
||||
const url = process.env.BTCPAY_URL
|
||||
const apiKey = process.env.BTCPAY_API_KEY
|
||||
const storeId = process.env.BTCPAY_STORE_ID
|
||||
if (!url || !apiKey || !storeId) throw new Error('BTCPay configuration missing: BTCPAY_URL, BTCPAY_API_KEY, BTCPAY_STORE_ID required')
|
||||
return { url: url.replace(/\/$/, ''), apiKey, storeId }
|
||||
}
|
||||
|
||||
async function btcpayFetch(path: string, options: RequestInit = {}): Promise<Response> {
|
||||
const { url, apiKey } = getConfig()
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), TIMEOUT)
|
||||
try {
|
||||
return await fetch(`${url}${path}`, { ...options, signal: controller.signal, headers: { Authorization: `token ${apiKey}`, 'Content-Type': 'application/json', ...options.headers } })
|
||||
} finally { clearTimeout(timer) }
|
||||
}
|
||||
|
||||
export interface CreateInvoiceParams { amountSats: number; orderId: string; redirectUrl: string }
|
||||
export interface BtcPayInvoice { id: string; checkoutLink: string; status: string; amount: string; currency: string }
|
||||
|
||||
export async function createInvoice(params: CreateInvoiceParams): Promise<BtcPayInvoice> {
|
||||
const { storeId } = getConfig()
|
||||
const res = await btcpayFetch(`/api/v1/stores/${storeId}/invoices`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ amount: String(params.amountSats), currency: 'SATS', metadata: { orderId: params.orderId }, checkout: { redirectURL: params.redirectUrl, redirectAutomatically: true } }),
|
||||
})
|
||||
if (!res.ok) { const text = await res.text(); throw new Error(`BTCPay invoice creation failed: ${res.status} ${text}`) }
|
||||
return res.json() as Promise<BtcPayInvoice>
|
||||
}
|
||||
|
||||
export async function getInvoice(invoiceId: string): Promise<BtcPayInvoice> {
|
||||
const { storeId } = getConfig()
|
||||
const res = await btcpayFetch(`/api/v1/stores/${storeId}/invoices/${invoiceId}`)
|
||||
if (!res.ok) throw new Error(`BTCPay invoice fetch failed: ${res.status}`)
|
||||
return res.json() as Promise<BtcPayInvoice>
|
||||
}
|
||||
|
||||
export function validateWebhookSignature(body: string, signature: string): boolean {
|
||||
const secret = process.env.BTCPAY_WEBHOOK_SECRET
|
||||
if (!secret) return false
|
||||
const expected = crypto.createHmac('sha256', secret).update(body).digest('hex')
|
||||
const sigBuf = Buffer.from(signature.replace('sha256=', ''), 'hex')
|
||||
const expectedBuf = Buffer.from(expected, 'hex')
|
||||
if (sigBuf.length !== expectedBuf.length) return false
|
||||
return crypto.timingSafeEqual(sigBuf, expectedBuf)
|
||||
}
|
||||
33
server/lib/crypto.ts
Normal file
33
server/lib/crypto.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import crypto from 'node:crypto'
|
||||
|
||||
const ALGORITHM = 'chacha20-poly1305'
|
||||
const NONCE_LENGTH = 12
|
||||
const TAG_LENGTH = 16
|
||||
|
||||
function getKey(): Buffer {
|
||||
const hex = process.env.ENCRYPTION_KEY
|
||||
if (!hex || hex.length !== 64) {
|
||||
throw new Error('ENCRYPTION_KEY must be a 64-character hex string (32 bytes)')
|
||||
}
|
||||
return Buffer.from(hex, 'hex')
|
||||
}
|
||||
|
||||
export function encrypt(plaintext: string): string {
|
||||
const key = getKey()
|
||||
const nonce = crypto.randomBytes(NONCE_LENGTH)
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, key, nonce, { authTagLength: TAG_LENGTH })
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
|
||||
const tag = cipher.getAuthTag()
|
||||
return Buffer.concat([nonce, tag, encrypted]).toString('hex')
|
||||
}
|
||||
|
||||
export function decrypt(ciphertext: string): string {
|
||||
const key = getKey()
|
||||
const buf = Buffer.from(ciphertext, 'hex')
|
||||
const nonce = buf.subarray(0, NONCE_LENGTH)
|
||||
const tag = buf.subarray(NONCE_LENGTH, NONCE_LENGTH + TAG_LENGTH)
|
||||
const encrypted = buf.subarray(NONCE_LENGTH + TAG_LENGTH)
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, key, nonce, { authTagLength: TAG_LENGTH })
|
||||
decipher.setAuthTag(tag)
|
||||
return decipher.update(encrypted) + decipher.final('utf8')
|
||||
}
|
||||
24
server/lib/mailer.ts
Normal file
24
server/lib/mailer.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import nodemailer from 'nodemailer'
|
||||
|
||||
let transporter: nodemailer.Transporter | null = null
|
||||
|
||||
function getTransporter(): nodemailer.Transporter | null {
|
||||
if (transporter) return transporter
|
||||
const host = process.env.SMTP_HOST
|
||||
if (!host) return null
|
||||
transporter = nodemailer.createTransport({ host, port: Number(process.env.SMTP_PORT) || 587, secure: Number(process.env.SMTP_PORT) === 465, auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS } })
|
||||
return transporter
|
||||
}
|
||||
|
||||
export async function sendOrderConfirmation(email: string, orderId: string, totalSats: number): Promise<void> {
|
||||
const t = getTransporter()
|
||||
if (!t) return
|
||||
await t.sendMail({ from: process.env.SMTP_FROM || 'orders@antonym.fashion', to: email, subject: `Order Confirmed - ${orderId}`, text: `Your order ${orderId} for ${totalSats.toLocaleString()} sats has been confirmed.\n\nTrack your order: ${process.env.SITE_URL || 'http://localhost:3333'}/order/${orderId}` })
|
||||
}
|
||||
|
||||
export async function sendStatusUpdate(email: string, orderId: string, status: string, note?: string): Promise<void> {
|
||||
const t = getTransporter()
|
||||
if (!t) return
|
||||
const noteText = note ? `\n\nNote: ${note}` : ''
|
||||
await t.sendMail({ from: process.env.SMTP_FROM || 'orders@antonym.fashion', to: email, subject: `Order ${orderId} - ${status.charAt(0).toUpperCase() + status.slice(1)}`, text: `Your order ${orderId} status has been updated to: ${status}${noteText}\n\nTrack your order: ${process.env.SITE_URL || 'http://localhost:3333'}/order/${orderId}` })
|
||||
}
|
||||
28
server/lib/nostr.ts
Normal file
28
server/lib/nostr.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export async function sendDm(recipientPubkey: string, message: string): Promise<void> {
|
||||
const privkeyHex = process.env.NOSTR_PRIVATE_KEY
|
||||
if (!privkeyHex || !recipientPubkey) return
|
||||
|
||||
try {
|
||||
const { finalizeEvent } = await import('nostr-tools/pure')
|
||||
const { encrypt } = await import('nostr-tools/nip04')
|
||||
const privkey = Uint8Array.from(Buffer.from(privkeyHex, 'hex'))
|
||||
const ciphertext = await encrypt(privkey, recipientPubkey, message)
|
||||
const event = finalizeEvent({ kind: 4, created_at: Math.floor(Date.now() / 1000), tags: [['p', recipientPubkey]], content: ciphertext }, privkey)
|
||||
privkey.fill(0)
|
||||
|
||||
const { Relay } = await import('nostr-tools/relay')
|
||||
const relays = ['wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.nostr.band']
|
||||
for (const url of relays) {
|
||||
try { const relay = await Relay.connect(url); await relay.publish(event); relay.close() } catch {}
|
||||
}
|
||||
} catch { console.error('Failed to send Nostr DM') }
|
||||
}
|
||||
|
||||
export async function notifyOrderConfirmed(pubkey: string, orderId: string, totalSats: number): Promise<void> {
|
||||
await sendDm(pubkey, `Your Antonym order ${orderId} for ${totalSats.toLocaleString()} sats has been confirmed. Track it at: ${process.env.SITE_URL || 'http://localhost:3333'}/order/${orderId}`)
|
||||
}
|
||||
|
||||
export async function notifyStatusUpdate(pubkey: string, orderId: string, status: string, note?: string): Promise<void> {
|
||||
const noteText = note ? ` — ${note}` : ''
|
||||
await sendDm(pubkey, `Your Antonym order ${orderId} has been updated to: ${status}${noteText}`)
|
||||
}
|
||||
48
server/middleware/adminAuth.ts
Normal file
48
server/middleware/adminAuth.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { Request, Response, NextFunction } from 'express'
|
||||
import crypto from 'node:crypto'
|
||||
import { getDb } from '../db/connection.js'
|
||||
|
||||
export function adminAuth(req: Request, res: Response, next: NextFunction): void {
|
||||
const token = req.cookies?.admin_session
|
||||
if (!token) { res.status(401).json({ error: { code: 'UNAUTHORIZED', message: 'Authentication required' } }); return }
|
||||
const db = getDb()
|
||||
const session = db.prepare("SELECT token FROM admin_sessions WHERE token = ? AND expires_at > datetime('now')").get(token) as { token: string } | undefined
|
||||
if (!session) { res.status(401).json({ error: { code: 'SESSION_EXPIRED', message: 'Session expired' } }); return }
|
||||
next()
|
||||
}
|
||||
|
||||
const loginAttempts = new Map<string, { count: number; resetAt: number }>()
|
||||
|
||||
export function rateLimit(req: Request, res: Response, next: NextFunction): void {
|
||||
const ip = req.ip || 'unknown'
|
||||
const now = Date.now()
|
||||
const entry = loginAttempts.get(ip)
|
||||
if (entry) {
|
||||
if (now > entry.resetAt) { loginAttempts.set(ip, { count: 1, resetAt: now + 60_000 }) }
|
||||
else if (entry.count >= 5) { res.status(429).json({ error: { code: 'RATE_LIMITED', message: 'Too many attempts' } }); return }
|
||||
else { entry.count++ }
|
||||
} else { loginAttempts.set(ip, { count: 1, resetAt: now + 60_000 }) }
|
||||
next()
|
||||
}
|
||||
|
||||
export function createSession(): string {
|
||||
const token = crypto.randomBytes(32).toString('hex')
|
||||
const db = getDb()
|
||||
db.prepare("INSERT INTO admin_sessions (token, expires_at) VALUES (?, datetime('now', '+24 hours'))").run(token)
|
||||
db.prepare("DELETE FROM admin_sessions WHERE expires_at < datetime('now')").run()
|
||||
return token
|
||||
}
|
||||
|
||||
export function deleteSession(token: string): void {
|
||||
const db = getDb()
|
||||
db.prepare('DELETE FROM admin_sessions WHERE token = ?').run(token)
|
||||
}
|
||||
|
||||
export function verifyPassword(input: string): boolean {
|
||||
const expected = process.env.ADMIN_PASSWORD
|
||||
if (!expected) return false
|
||||
const inputBuf = Buffer.from(input)
|
||||
const expectedBuf = Buffer.from(expected)
|
||||
if (inputBuf.length !== expectedBuf.length) return false
|
||||
return crypto.timingSafeEqual(inputBuf, expectedBuf)
|
||||
}
|
||||
20
server/middleware/validate.ts
Normal file
20
server/middleware/validate.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { Request, Response, NextFunction } from 'express'
|
||||
|
||||
export function requireBody(...fields: string[]) {
|
||||
return (req: Request, res: Response, next: NextFunction): void => {
|
||||
const missing = fields.filter((f) => req.body[f] === undefined || req.body[f] === null)
|
||||
if (missing.length > 0) { res.status(400).json({ error: { code: 'MISSING_FIELDS', message: `Missing required fields: ${missing.join(', ')}` } }); return }
|
||||
next()
|
||||
}
|
||||
}
|
||||
|
||||
export function sanitizeString(val: unknown): string {
|
||||
if (typeof val !== 'string') return ''
|
||||
return val.trim().slice(0, 10_000)
|
||||
}
|
||||
|
||||
export function sanitizeInt(val: unknown): number | null {
|
||||
const n = Number(val)
|
||||
if (!Number.isInteger(n) || n < 0) return null
|
||||
return n
|
||||
}
|
||||
22
server/routes/admin.ts
Normal file
22
server/routes/admin.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Router } from 'express'
|
||||
import { rateLimit, createSession, deleteSession, verifyPassword, adminAuth } from '../middleware/adminAuth.js'
|
||||
import { requireBody } from '../middleware/validate.js'
|
||||
|
||||
export const adminRouter = Router()
|
||||
|
||||
adminRouter.post('/login', rateLimit, requireBody('password'), (req, res) => {
|
||||
const { password } = req.body as { password: string }
|
||||
if (!verifyPassword(password)) { res.status(401).json({ error: { code: 'INVALID_PASSWORD', message: 'Invalid password' } }); return }
|
||||
const token = createSession()
|
||||
res.cookie('admin_session', token, { httpOnly: true, sameSite: 'strict', secure: process.env.NODE_ENV === 'production', maxAge: 24 * 60 * 60 * 1000 })
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
adminRouter.post('/logout', adminAuth, (req, res) => {
|
||||
const token = req.cookies?.admin_session
|
||||
if (token) deleteSession(token)
|
||||
res.clearCookie('admin_session')
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
adminRouter.get('/verify', adminAuth, (_req, res) => { res.json({ ok: true }) })
|
||||
49
server/routes/adminOrders.ts
Normal file
49
server/routes/adminOrders.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Router } from 'express'
|
||||
import { getDb } from '../db/connection.js'
|
||||
import { adminAuth } from '../middleware/adminAuth.js'
|
||||
import { requireBody, sanitizeString } from '../middleware/validate.js'
|
||||
import { decrypt } from '../lib/crypto.js'
|
||||
import { sendStatusUpdate } from '../lib/mailer.js'
|
||||
import { notifyStatusUpdate } from '../lib/nostr.js'
|
||||
import type { Order, OrderItem, OrderEvent, OrderStatus, ShippingAddress } from '../../shared/types.js'
|
||||
|
||||
export const adminOrdersRouter = Router()
|
||||
adminOrdersRouter.use(adminAuth)
|
||||
|
||||
interface OrderRow { id: string; nostr_pubkey: string | null; email: string | null; btcpay_invoice_id: string | null; status: string; shipping_address_encrypted: string | null; items: string; total_sats: number; note: string | null; created_at: string; updated_at: string }
|
||||
|
||||
const VALID_STATUSES: OrderStatus[] = ['pending', 'paid', 'confirmed', 'shipped', 'delivered', 'cancelled']
|
||||
|
||||
adminOrdersRouter.get('/', (req, res) => {
|
||||
const db = getDb()
|
||||
const status = req.query.status as string | undefined
|
||||
let rows: OrderRow[]
|
||||
if (status) { rows = db.prepare('SELECT * FROM orders WHERE status = ? ORDER BY created_at DESC').all(status) as OrderRow[] }
|
||||
else { rows = db.prepare('SELECT * FROM orders ORDER BY created_at DESC').all() as OrderRow[] }
|
||||
res.json(rows.map((row) => ({ id: row.id, nostrPubkey: row.nostr_pubkey, email: row.email, btcpayInvoiceId: row.btcpay_invoice_id, status: row.status as OrderStatus, items: JSON.parse(row.items) as OrderItem[], totalSats: row.total_sats, note: row.note, createdAt: row.created_at, updatedAt: row.updated_at })))
|
||||
})
|
||||
|
||||
adminOrdersRouter.get('/:id', (req, res) => {
|
||||
const db = getDb()
|
||||
const row = db.prepare('SELECT * FROM orders WHERE id = ?').get(req.params.id) as OrderRow | undefined
|
||||
if (!row) { res.status(404).json({ error: { code: 'NOT_FOUND', message: 'Order not found' } }); return }
|
||||
let shippingAddress: ShippingAddress | null = null
|
||||
if (row.shipping_address_encrypted) { try { shippingAddress = JSON.parse(decrypt(row.shipping_address_encrypted)) as ShippingAddress } catch { shippingAddress = null } }
|
||||
const events = db.prepare('SELECT * FROM order_events WHERE order_id = ? ORDER BY created_at ASC').all(req.params.id) as OrderEvent[]
|
||||
const order: Order & { shippingAddress: ShippingAddress | null; events: OrderEvent[] } = { id: row.id, nostrPubkey: row.nostr_pubkey, email: row.email, btcpayInvoiceId: row.btcpay_invoice_id, status: row.status as OrderStatus, items: JSON.parse(row.items) as OrderItem[], totalSats: row.total_sats, note: row.note, createdAt: row.created_at, updatedAt: row.updated_at, shippingAddress, events }
|
||||
res.json(order)
|
||||
})
|
||||
|
||||
adminOrdersRouter.patch('/:id/status', requireBody('status'), (req, res) => {
|
||||
const db = getDb()
|
||||
const { status, note } = req.body as { status: string; note?: string }
|
||||
if (!VALID_STATUSES.includes(status as OrderStatus)) { res.status(400).json({ error: { code: 'INVALID_STATUS', message: `Status must be one of: ${VALID_STATUSES.join(', ')}` } }); return }
|
||||
const order = db.prepare('SELECT * FROM orders WHERE id = ?').get(req.params.id) as OrderRow | undefined
|
||||
if (!order) { res.status(404).json({ error: { code: 'NOT_FOUND', message: 'Order not found' } }); return }
|
||||
const noteText = note ? sanitizeString(note) : null
|
||||
db.prepare("UPDATE orders SET status = ?, updated_at = datetime('now') WHERE id = ?").run(status, req.params.id)
|
||||
db.prepare('INSERT INTO order_events (order_id, status, note) VALUES (?, ?, ?)').run(req.params.id, status, noteText)
|
||||
if (order.email) sendStatusUpdate(order.email, order.id, status, noteText ?? undefined).catch(() => {})
|
||||
if (order.nostr_pubkey) notifyStatusUpdate(order.nostr_pubkey, order.id, status, noteText ?? undefined).catch(() => {})
|
||||
res.json({ ok: true, status })
|
||||
})
|
||||
67
server/routes/adminProducts.ts
Normal file
67
server/routes/adminProducts.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Router } from 'express'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { getDb } from '../db/connection.js'
|
||||
import { adminAuth } from '../middleware/adminAuth.js'
|
||||
import { requireBody, sanitizeString, sanitizeInt } from '../middleware/validate.js'
|
||||
import { rowToProduct } from './products.js'
|
||||
|
||||
export const adminProductsRouter = Router()
|
||||
adminProductsRouter.use(adminAuth)
|
||||
|
||||
interface ProductRow { id: string; name: string; slug: string; description: string; price_sats: number; images: string; sizes: string; category: string; is_active: number; created_at: string; updated_at: string }
|
||||
|
||||
adminProductsRouter.get('/', (_req, res) => {
|
||||
const db = getDb()
|
||||
const rows = db.prepare('SELECT * FROM products ORDER BY created_at DESC').all() as ProductRow[]
|
||||
res.json(rows.map(rowToProduct))
|
||||
})
|
||||
|
||||
adminProductsRouter.post('/', requireBody('name', 'slug', 'priceSats'), (req, res) => {
|
||||
const db = getDb()
|
||||
const { name, slug, description, priceSats, images, sizes, category } = req.body
|
||||
const price = sanitizeInt(priceSats)
|
||||
if (price === null || price <= 0) { res.status(400).json({ error: { code: 'INVALID_PRICE', message: 'Price must be a positive integer (sats)' } }); return }
|
||||
const id = nanoid()
|
||||
try {
|
||||
db.prepare('INSERT INTO products (id, name, slug, description, price_sats, images, sizes, category) VALUES (?, ?, ?, ?, ?, ?, ?, ?)').run(id, sanitizeString(name), sanitizeString(slug), sanitizeString(description || ''), price, JSON.stringify(images || []), JSON.stringify(sizes || []), sanitizeString(category || 'general'))
|
||||
const row = db.prepare('SELECT * FROM products WHERE id = ?').get(id) as ProductRow
|
||||
res.status(201).json(rowToProduct(row))
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && err.message.includes('UNIQUE')) { res.status(409).json({ error: { code: 'SLUG_EXISTS', message: 'A product with this slug already exists' } }); return }
|
||||
throw err
|
||||
}
|
||||
})
|
||||
|
||||
adminProductsRouter.put('/:id', (req, res) => {
|
||||
const db = getDb()
|
||||
const { name, slug, description, priceSats, images, sizes, category, isActive } = req.body
|
||||
const existing = db.prepare('SELECT id FROM products WHERE id = ?').get(req.params.id)
|
||||
if (!existing) { res.status(404).json({ error: { code: 'NOT_FOUND', message: 'Product not found' } }); return }
|
||||
const price = sanitizeInt(priceSats)
|
||||
if (price !== null && price <= 0) { res.status(400).json({ error: { code: 'INVALID_PRICE', message: 'Price must be a positive integer (sats)' } }); return }
|
||||
try {
|
||||
db.prepare("UPDATE products SET name = COALESCE(?, name), slug = COALESCE(?, slug), description = COALESCE(?, description), price_sats = COALESCE(?, price_sats), images = COALESCE(?, images), sizes = COALESCE(?, sizes), category = COALESCE(?, category), is_active = COALESCE(?, is_active), updated_at = datetime('now') WHERE id = ?").run(name ? sanitizeString(name) : null, slug ? sanitizeString(slug) : null, description !== undefined ? sanitizeString(description) : null, price, images ? JSON.stringify(images) : null, sizes ? JSON.stringify(sizes) : null, category ? sanitizeString(category) : null, isActive !== undefined ? (isActive ? 1 : 0) : null, req.params.id)
|
||||
const row = db.prepare('SELECT * FROM products WHERE id = ?').get(req.params.id) as ProductRow
|
||||
res.json(rowToProduct(row))
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && err.message.includes('UNIQUE')) { res.status(409).json({ error: { code: 'SLUG_EXISTS', message: 'A product with this slug already exists' } }); return }
|
||||
throw err
|
||||
}
|
||||
})
|
||||
|
||||
adminProductsRouter.delete('/:id', (req, res) => {
|
||||
const db = getDb()
|
||||
const result = db.prepare("UPDATE products SET is_active = 0, updated_at = datetime('now') WHERE id = ?").run(req.params.id)
|
||||
if (result.changes === 0) { res.status(404).json({ error: { code: 'NOT_FOUND', message: 'Product not found' } }); return }
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
adminProductsRouter.patch('/:id/stock', requireBody('sizes'), (req, res) => {
|
||||
const db = getDb()
|
||||
const { sizes } = req.body
|
||||
if (!Array.isArray(sizes)) { res.status(400).json({ error: { code: 'INVALID_SIZES', message: 'Sizes must be an array' } }); return }
|
||||
const result = db.prepare("UPDATE products SET sizes = ?, updated_at = datetime('now') WHERE id = ?").run(JSON.stringify(sizes), req.params.id)
|
||||
if (result.changes === 0) { res.status(404).json({ error: { code: 'NOT_FOUND', message: 'Product not found' } }); return }
|
||||
const row = db.prepare('SELECT * FROM products WHERE id = ?').get(req.params.id) as ProductRow
|
||||
res.json(rowToProduct(row))
|
||||
})
|
||||
56
server/routes/orders.ts
Normal file
56
server/routes/orders.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Router } from 'express'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { getDb } from '../db/connection.js'
|
||||
import { encrypt } from '../lib/crypto.js'
|
||||
import { createInvoice } from '../lib/btcpay.js'
|
||||
import { requireBody, sanitizeString } from '../middleware/validate.js'
|
||||
import type { CreateOrderRequest, Order, OrderItem, OrderEvent, OrderStatus } from '../../shared/types.js'
|
||||
|
||||
export const ordersRouter = Router()
|
||||
|
||||
interface OrderRow { id: string; nostr_pubkey: string | null; email: string | null; btcpay_invoice_id: string | null; status: string; shipping_address_encrypted: string | null; items: string; total_sats: number; note: string | null; created_at: string; updated_at: string }
|
||||
|
||||
function rowToOrder(row: OrderRow): Order {
|
||||
return { id: row.id, nostrPubkey: row.nostr_pubkey, email: row.email, btcpayInvoiceId: row.btcpay_invoice_id, status: row.status as OrderStatus, items: JSON.parse(row.items) as OrderItem[], totalSats: row.total_sats, note: row.note, createdAt: row.created_at, updatedAt: row.updated_at }
|
||||
}
|
||||
|
||||
ordersRouter.post('/', requireBody('items', 'shippingAddress'), async (req, res) => {
|
||||
try {
|
||||
const body = req.body as CreateOrderRequest
|
||||
const db = getDb()
|
||||
if (!Array.isArray(body.items) || body.items.length === 0) { res.status(400).json({ error: { code: 'INVALID_ITEMS', message: 'Order must contain items' } }); return }
|
||||
|
||||
const orderItems: OrderItem[] = []
|
||||
let totalSats = 0
|
||||
|
||||
for (const item of body.items) {
|
||||
const product = db.prepare('SELECT * FROM products WHERE id = ? AND is_active = 1').get(item.productId) as { name: string; price_sats: number; sizes: string } | undefined
|
||||
if (!product) { res.status(400).json({ error: { code: 'INVALID_PRODUCT', message: `Product ${item.productId} not found` } }); return }
|
||||
const sizes = JSON.parse(product.sizes) as { size: string; stock: number }[]
|
||||
const sizeEntry = sizes.find((s) => s.size === item.size)
|
||||
if (!sizeEntry || sizeEntry.stock < item.quantity) { res.status(400).json({ error: { code: 'INSUFFICIENT_STOCK', message: `Insufficient stock for ${product.name} size ${item.size}` } }); return }
|
||||
const lineTotal = product.price_sats * item.quantity
|
||||
totalSats += lineTotal
|
||||
orderItems.push({ productId: item.productId, productName: product.name, size: item.size, quantity: item.quantity, priceSats: product.price_sats })
|
||||
}
|
||||
|
||||
const orderId = nanoid()
|
||||
const encryptedAddress = encrypt(JSON.stringify(body.shippingAddress))
|
||||
const siteUrl = process.env.SITE_URL || 'http://localhost:3333'
|
||||
const invoice = await createInvoice({ amountSats: totalSats, orderId, redirectUrl: `${siteUrl}/order/${orderId}` })
|
||||
|
||||
db.prepare('INSERT INTO orders (id, nostr_pubkey, email, btcpay_invoice_id, status, shipping_address_encrypted, items, total_sats, note) VALUES (?, ?, ?, ?, \'pending\', ?, ?, ?, ?)').run(orderId, body.nostrPubkey ? sanitizeString(body.nostrPubkey) : null, body.email ? sanitizeString(body.email) : null, invoice.id, encryptedAddress, JSON.stringify(orderItems), totalSats, body.note ? sanitizeString(body.note) : null)
|
||||
db.prepare('INSERT INTO order_events (order_id, status, note) VALUES (?, ?, ?)').run(orderId, 'pending', 'Order created')
|
||||
|
||||
res.status(201).json({ orderId, invoiceUrl: invoice.checkoutLink, invoiceId: invoice.id })
|
||||
} catch (err) { console.error('Order creation failed:', err); res.status(500).json({ error: { code: 'ORDER_FAILED', message: 'Failed to create order' } }) }
|
||||
})
|
||||
|
||||
ordersRouter.get('/:id', (req, res) => {
|
||||
const db = getDb()
|
||||
const row = db.prepare('SELECT * FROM orders WHERE id = ?').get(req.params.id) as OrderRow | undefined
|
||||
if (!row) { res.status(404).json({ error: { code: 'NOT_FOUND', message: 'Order not found' } }); return }
|
||||
const order = rowToOrder(row)
|
||||
const events = db.prepare('SELECT * FROM order_events WHERE order_id = ? ORDER BY created_at ASC').all(req.params.id) as OrderEvent[]
|
||||
res.json({ ...order, events })
|
||||
})
|
||||
27
server/routes/products.ts
Normal file
27
server/routes/products.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Router } from 'express'
|
||||
import { getDb } from '../db/connection.js'
|
||||
import type { Product, SizeStock } from '../../shared/types.js'
|
||||
|
||||
export const productsRouter = Router()
|
||||
|
||||
interface ProductRow { id: string; name: string; slug: string; description: string; price_sats: number; images: string; sizes: string; category: string; is_active: number; created_at: string; updated_at: string }
|
||||
|
||||
export function rowToProduct(row: ProductRow): Product {
|
||||
return { id: row.id, name: row.name, slug: row.slug, description: row.description, priceSats: row.price_sats, images: JSON.parse(row.images) as string[], sizes: JSON.parse(row.sizes) as SizeStock[], category: row.category, isActive: row.is_active === 1, createdAt: row.created_at, updatedAt: row.updated_at }
|
||||
}
|
||||
|
||||
productsRouter.get('/', (req, res) => {
|
||||
const db = getDb()
|
||||
const category = req.query.category as string | undefined
|
||||
let rows: ProductRow[]
|
||||
if (category) { rows = db.prepare('SELECT * FROM products WHERE is_active = 1 AND category = ? ORDER BY created_at DESC').all(category) as ProductRow[] }
|
||||
else { rows = db.prepare('SELECT * FROM products WHERE is_active = 1 ORDER BY created_at DESC').all() as ProductRow[] }
|
||||
res.json(rows.map(rowToProduct))
|
||||
})
|
||||
|
||||
productsRouter.get('/:slug', (req, res) => {
|
||||
const db = getDb()
|
||||
const row = db.prepare('SELECT * FROM products WHERE slug = ? AND is_active = 1').get(req.params.slug) as ProductRow | undefined
|
||||
if (!row) { res.status(404).json({ error: { code: 'NOT_FOUND', message: 'Product not found' } }); return }
|
||||
res.json(rowToProduct(row))
|
||||
})
|
||||
47
server/routes/webhooks.ts
Normal file
47
server/routes/webhooks.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Router } from 'express'
|
||||
import { getDb } from '../db/connection.js'
|
||||
import { validateWebhookSignature } from '../lib/btcpay.js'
|
||||
import { sendOrderConfirmation } from '../lib/mailer.js'
|
||||
import { notifyOrderConfirmed } from '../lib/nostr.js'
|
||||
import type { SizeStock } from '../../shared/types.js'
|
||||
|
||||
export const webhooksRouter = Router()
|
||||
|
||||
interface OrderRow { id: string; nostr_pubkey: string | null; email: string | null; items: string; total_sats: number; status: string }
|
||||
|
||||
webhooksRouter.post('/btcpay', async (req, res) => {
|
||||
try {
|
||||
const signature = req.headers['btcpay-sig'] as string | undefined
|
||||
const rawBody = JSON.stringify(req.body)
|
||||
if (!signature || !validateWebhookSignature(rawBody, signature)) { res.status(401).json({ error: { code: 'INVALID_SIGNATURE', message: 'Invalid webhook signature' } }); return }
|
||||
|
||||
const { type, invoiceId } = req.body as { type: string; invoiceId: string }
|
||||
const db = getDb()
|
||||
const order = db.prepare('SELECT * FROM orders WHERE btcpay_invoice_id = ?').get(invoiceId) as OrderRow | undefined
|
||||
if (!order) { res.status(404).json({ error: { code: 'ORDER_NOT_FOUND', message: 'Order not found for invoice' } }); return }
|
||||
|
||||
if (type === 'InvoiceSettled' || type === 'InvoicePaymentSettled') {
|
||||
if (order.status !== 'pending') { res.json({ ok: true, message: 'Already processed' }); return }
|
||||
const items = JSON.parse(order.items) as { productId: string; size: string; quantity: number }[]
|
||||
const decrementStock = db.transaction(() => {
|
||||
for (const item of items) {
|
||||
const product = db.prepare('SELECT sizes FROM products WHERE id = ?').get(item.productId) as { sizes: string } | undefined
|
||||
if (!product) continue
|
||||
const sizes = JSON.parse(product.sizes) as SizeStock[]
|
||||
const sizeEntry = sizes.find((s) => s.size === item.size)
|
||||
if (sizeEntry) sizeEntry.stock = Math.max(0, sizeEntry.stock - item.quantity)
|
||||
db.prepare("UPDATE products SET sizes = ?, updated_at = datetime('now') WHERE id = ?").run(JSON.stringify(sizes), item.productId)
|
||||
}
|
||||
db.prepare("UPDATE orders SET status = ?, updated_at = datetime('now') WHERE id = ?").run('paid', order.id)
|
||||
db.prepare('INSERT INTO order_events (order_id, status, note) VALUES (?, ?, ?)').run(order.id, 'paid', 'Payment confirmed via BTCPay')
|
||||
})
|
||||
decrementStock()
|
||||
if (order.email) sendOrderConfirmation(order.email, order.id, order.total_sats).catch(() => {})
|
||||
if (order.nostr_pubkey) notifyOrderConfirmed(order.nostr_pubkey, order.id, order.total_sats).catch(() => {})
|
||||
} else if (type === 'InvoiceExpired') {
|
||||
db.prepare("UPDATE orders SET status = ?, updated_at = datetime('now') WHERE id = ?").run('cancelled', order.id)
|
||||
db.prepare('INSERT INTO order_events (order_id, status, note) VALUES (?, ?, ?)').run(order.id, 'cancelled', 'Invoice expired')
|
||||
}
|
||||
res.json({ ok: true })
|
||||
} catch (err) { console.error('Webhook processing failed:', err); res.status(500).json({ error: { code: 'WEBHOOK_FAILED', message: 'Webhook processing failed' } }) }
|
||||
})
|
||||
20
server/tsconfig.json
Normal file
20
server/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022"],
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"paths": {
|
||||
"@shared/*": ["../shared/*"]
|
||||
},
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["./**/*.ts", "../shared/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user