From 0bb38fa22561c1ce77cbda393ed256fe99d6924d Mon Sep 17 00:00:00 2001 From: notshop Date: Sun, 26 Apr 2026 16:36:19 +0000 Subject: [PATCH] chore: add server/routes.ts --- server/routes.ts | 640 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 640 insertions(+) create mode 100644 server/routes.ts diff --git a/server/routes.ts b/server/routes.ts new file mode 100644 index 0000000..60fa24a --- /dev/null +++ b/server/routes.ts @@ -0,0 +1,640 @@ +import type { Express, Request, Response } from "express"; +import { db } from "./db"; +import { + products, productVariants, categories, brands, + customers, addresses, paymentMethods, + carts, cartItems, + orders, orderItems, orderEvents, + discountCodes, adminUsers, +} from "../shared/schema"; +import { + eq, and, desc, asc, ilike, inArray, sql, or, +} from "drizzle-orm"; +import bcrypt from "bcrypt"; +import crypto from "crypto"; +import { chargeOpaqueData, refundTransaction, createCustomerProfile, chargeCustomerProfile } from "./payments/authorizenet"; +import { createPaymentIntent, verifyPaymentIntent, isStripeConfigured, refundPaymentIntent } from "./payments/stripe"; +import { sendOrderConfirmation, sendShippingNotification } from "./email"; +import { requestMagicLink, verifyMagicLink, requireCustomer, requireAdmin, logout, me } from "./auth"; + +function generateOrderNumber(): string { + return `NS-${Date.now().toString(36).toUpperCase()}`; +} + +function toCents(value: string | number): number { + return Math.round(parseFloat(String(value)) * 100); +} + +export function registerRoutes(app: Express) { + + // ── Config (public) ────────────────────────────────────────────────────── + app.get("/api/config", (_req, res) => { + res.json({ + storeName: process.env.STORE_NAME || "My Store", + authorizeNetClientKey: process.env.AUTHORIZE_NET_PUBLIC_CLIENT_KEY || null, + authorizeNetApiLoginId: process.env.AUTHORIZE_NET_API_LOGIN_ID || null, + authorizeNetSandbox: process.env.AUTHORIZE_NET_SANDBOX !== "false", + stripePublishableKey: process.env.STRIPE_PUBLISHABLE_KEY || null, + stripeEnabled: isStripeConfigured(), + }); + }); + + // ── Auth ────────────────────────────────────────────────────────────────── + app.post("/api/auth/magic-link", requestMagicLink); + app.get("/api/auth/verify", verifyMagicLink); + app.post("/api/auth/logout", logout); + app.get("/api/auth/me", me); + + // Admin login + app.post("/api/admin/login", async (req: Request, res: Response) => { + const { email, password } = req.body as { email?: string; password?: string }; + if (!email || !password) return res.status(400).json({ error: "Email and password required" }); + const [admin] = await db.select().from(adminUsers).where(eq(adminUsers.email, email.toLowerCase())); + if (!admin || !admin.passwordHash) return res.status(401).json({ error: "Invalid credentials" }); + const ok = await bcrypt.compare(password, admin.passwordHash); + if (!ok) return res.status(401).json({ error: "Invalid credentials" }); + (req.session as any).adminUserId = admin.id; + res.json({ ok: true, admin: { id: admin.id, email: admin.email, name: admin.name, role: admin.role } }); + }); + + app.post("/api/admin/logout", (req: Request, res: Response) => { + req.session.destroy(() => res.json({ ok: true })); + }); + + app.get("/api/admin/me", requireAdmin, async (req: Request, res: Response) => { + const [admin] = await db.select({ id: adminUsers.id, email: adminUsers.email, name: adminUsers.name, role: adminUsers.role }) + .from(adminUsers).where(eq(adminUsers.id, (req.session as any).adminUserId)); + res.json({ admin: admin || null }); + }); + + // ── Products (public) ───────────────────────────────────────────────────── + app.get("/api/products", async (req: Request, res: Response) => { + const { q, category, brand, tag, limit = "48", page = "1" } = req.query as Record; + const pageSize = Math.min(parseInt(limit) || 48, 100); + const offset = (parseInt(page) - 1) * pageSize; + + const conditions = [eq(products.status, "active")]; + if (q) conditions.push(ilike(products.title, `%${q}%`)); + if (category) { + const [cat] = await db.select({ id: categories.id }).from(categories).where(eq(categories.slug, category)); + if (cat) conditions.push(eq(products.categoryId, cat.id)); + } + if (brand) { + const [b] = await db.select({ id: brands.id }).from(brands).where(eq(brands.slug, brand)); + if (b) conditions.push(eq(products.brandId, b.id)); + } + + const rows = await db + .select() + .from(products) + .where(and(...conditions)) + .orderBy(desc(products.createdAt)) + .limit(pageSize) + .offset(offset); + + const [{ count }] = await db + .select({ count: sql`count(*)` }) + .from(products) + .where(and(...conditions)); + + res.json({ products: rows, total: Number(count), page: parseInt(page), pageSize }); + }); + + app.get("/api/products/:handle", async (req: Request, res: Response) => { + const [product] = await db.select().from(products).where(eq(products.handle, req.params.handle)); + if (!product) return res.status(404).json({ error: "Not found" }); + const variants = await db.select().from(productVariants) + .where(eq(productVariants.productId, product.id)) + .orderBy(asc(productVariants.position)); + res.json({ product, variants }); + }); + + app.get("/api/categories", async (_req, res) => { + const rows = await db.select().from(categories).orderBy(asc(categories.position)); + res.json(rows); + }); + + app.get("/api/brands", async (_req, res) => { + const rows = await db.select().from(brands).orderBy(asc(brands.name)); + res.json(rows); + }); + + // ── Cart ────────────────────────────────────────────────────────────────── + async function getOrCreateCart(req: Request) { + const sessionId = req.sessionID; + const customerId = (req.session as any).customerId || null; + let [cart] = await db.select().from(carts) + .where(and(eq(carts.sessionId, sessionId), eq(carts.status, "active"))); + if (!cart) { + [cart] = await db.insert(carts).values({ sessionId, customerId, status: "active" }).returning(); + } + return cart; + } + + app.get("/api/cart", async (req: Request, res: Response) => { + const cart = await getOrCreateCart(req); + const items = await db.select().from(cartItems).where(eq(cartItems.cartId, cart.id)); + res.json({ cart, items }); + }); + + app.post("/api/cart/items", async (req: Request, res: Response) => { + const { productId, variantId, quantity = 1 } = req.body as { + productId?: number; variantId?: number; quantity?: number; + }; + if (!productId) return res.status(400).json({ error: "productId required" }); + + const [product] = await db.select().from(products).where(eq(products.id, productId)); + if (!product) return res.status(404).json({ error: "Product not found" }); + + let price = product.price; + let variantTitle: string | undefined; + let imageUrl: string | undefined; + + if (variantId) { + const [variant] = await db.select().from(productVariants).where(eq(productVariants.id, variantId)); + if (variant) { price = variant.price; variantTitle = variant.title; imageUrl = variant.imageUrl || undefined; } + } + + if (!imageUrl && Array.isArray(product.images) && product.images.length > 0) { + imageUrl = product.images[0] as string; + } + + const cart = await getOrCreateCart(req); + const [existing] = await db.select().from(cartItems) + .where(and(eq(cartItems.cartId, cart.id), eq(cartItems.productId, productId), + variantId ? eq(cartItems.variantId, variantId) : sql`${cartItems.variantId} IS NULL`)); + + if (existing) { + const [updated] = await db.update(cartItems) + .set({ quantity: existing.quantity + (quantity || 1) }) + .where(eq(cartItems.id, existing.id)) + .returning(); + return res.json(updated); + } + + const [item] = await db.insert(cartItems).values({ + cartId: cart.id, + productId, + variantId, + title: product.title, + variantTitle, + price, + quantity: quantity || 1, + imageUrl, + productHandle: product.handle, + }).returning(); + + res.json(item); + }); + + app.patch("/api/cart/items/:id", async (req: Request, res: Response) => { + const { quantity } = req.body as { quantity?: number }; + if (quantity === undefined) return res.status(400).json({ error: "quantity required" }); + if (quantity <= 0) { + await db.delete(cartItems).where(eq(cartItems.id, parseInt(req.params.id))); + return res.json({ deleted: true }); + } + const [item] = await db.update(cartItems).set({ quantity }).where(eq(cartItems.id, parseInt(req.params.id))).returning(); + res.json(item); + }); + + app.delete("/api/cart/items/:id", async (req: Request, res: Response) => { + await db.delete(cartItems).where(eq(cartItems.id, parseInt(req.params.id))); + res.json({ ok: true }); + }); + + app.post("/api/cart/discount", async (req: Request, res: Response) => { + const { code } = req.body as { code?: string }; + if (!code) return res.status(400).json({ error: "Code required" }); + + const [discount] = await db.select().from(discountCodes) + .where(and(eq(discountCodes.code, code.toUpperCase()), eq(discountCodes.active, true))); + + if (!discount) return res.status(404).json({ error: "Invalid or expired discount code" }); + if (discount.expiresAt && new Date() > new Date(discount.expiresAt)) { + return res.status(400).json({ error: "Discount code has expired" }); + } + if (discount.maxUses !== null && discount.usedCount >= discount.maxUses!) { + return res.status(400).json({ error: "Discount code has reached its usage limit" }); + } + + const cart = await getOrCreateCart(req); + await db.update(carts).set({ discountCode: code.toUpperCase() }).where(eq(carts.id, cart.id)); + res.json({ ok: true, discount: { code: discount.code, type: discount.type, value: discount.value } }); + }); + + app.delete("/api/cart/discount", async (req: Request, res: Response) => { + const cart = await getOrCreateCart(req); + await db.update(carts).set({ discountCode: null, discountAmount: null }).where(eq(carts.id, cart.id)); + res.json({ ok: true }); + }); + + // ── Checkout / Orders ───────────────────────────────────────────────────── + + // Authorize.net: create PaymentIntent-equivalent (nonce is submitted client-side via Accept.js) + // The frontend gets the opaque data and POSTs it here to complete the charge. + app.post("/api/checkout/authorizenet", async (req: Request, res: Response) => { + const { + dataDescriptor, dataValue, + email, firstName, lastName, + shippingAddress, billingAddress, + saveCard = false, + } = req.body as { + dataDescriptor?: string; + dataValue?: string; + email?: string; + firstName?: string; + lastName?: string; + shippingAddress?: any; + billingAddress?: any; + saveCard?: boolean; + }; + + if (!dataDescriptor || !dataValue || !email) { + return res.status(400).json({ error: "Payment data and email are required" }); + } + + const cart = await getOrCreateCart(req); + const items = await db.select().from(cartItems).where(eq(cartItems.cartId, cart.id)); + if (!items.length) return res.status(400).json({ error: "Cart is empty" }); + + let subtotal = items.reduce((sum, i) => sum + parseFloat(i.price) * i.quantity, 0); + + let discountAmount = 0; + if (cart.discountCode) { + const [discount] = await db.select().from(discountCodes) + .where(and(eq(discountCodes.code, cart.discountCode), eq(discountCodes.active, true))); + if (discount) { + if (discount.type === "percentage") { + discountAmount = subtotal * (parseFloat(discount.value) / 100); + } else { + discountAmount = parseFloat(discount.value); + } + discountAmount = Math.min(discountAmount, subtotal); + } + } + + const tax = 0; + const shipping = 0; + const total = Math.max(0, subtotal - discountAmount + tax + shipping); + const amountCents = toCents(total); + + const normalEmail = email.toLowerCase().trim(); + + let [customer] = await db.select().from(customers).where(eq(customers.email, normalEmail)); + if (!customer) { + [customer] = await db.insert(customers) + .values({ email: normalEmail, firstName, lastName }) + .returning(); + } + + const chargeResult = await chargeOpaqueData({ + dataDescriptor, + dataValue, + amountCents, + email: normalEmail, + description: `Order from ${process.env.STORE_NAME || "store"}`, + billingAddress: billingAddress || shippingAddress, + }); + + if (!chargeResult.success) { + return res.status(402).json({ error: chargeResult.error || "Payment declined" }); + } + + if (saveCard && customer) { + try { + const profileResult = await createCustomerProfile({ + email: normalEmail, + dataDescriptor, + dataValue, + }); + if (profileResult.success && profileResult.profileId) { + await db.update(customers).set({ authNetProfileId: profileResult.profileId }).where(eq(customers.id, customer.id)); + if (profileResult.paymentProfileId) { + await db.insert(paymentMethods).values({ + customerId: customer.id, + source: "authorizenet", + authNetPaymentProfileId: profileResult.paymentProfileId, + isDefault: true, + }); + } + } + } catch (_) {} + } + + const orderNumber = generateOrderNumber(); + const [order] = await db.insert(orders).values({ + orderNumber, + customerId: customer.id, + email: normalEmail, + status: "confirmed", + paymentStatus: "paid", + paymentProcessor: "authorizenet", + subtotal: subtotal.toFixed(2), + tax: tax.toFixed(2), + shipping: shipping.toFixed(2), + discountCode: cart.discountCode || undefined, + discountAmount: discountAmount > 0 ? discountAmount.toFixed(2) : "0.00", + total: total.toFixed(2), + authNetTransactionId: chargeResult.transactionId, + shippingAddress, + billingAddress: billingAddress || shippingAddress, + paymentVerified: true, + }).returning(); + + await db.insert(orderItems).values(items.map((i) => ({ + orderId: order.id, + productId: i.productId, + productHandle: i.productHandle || undefined, + variantId: i.variantId || undefined, + title: i.title, + variantTitle: i.variantTitle || undefined, + quantity: i.quantity, + price: i.price, + total: (parseFloat(i.price) * i.quantity).toFixed(2), + imageUrl: i.imageUrl || undefined, + }))); + + await db.insert(orderEvents).values({ + orderId: order.id, + type: "order_placed", + body: `Payment captured via Authorize.net. Transaction ID: ${chargeResult.transactionId}`, + actor: "system", + }); + + if (cart.discountCode) { + await db.update(discountCodes) + .set({ usedCount: sql`${discountCodes.usedCount} + 1` }) + .where(eq(discountCodes.code, cart.discountCode)); + } + + await db.update(carts).set({ status: "converted" }).where(eq(carts.id, cart.id)); + + await sendOrderConfirmation({ + email: normalEmail, + orderNumber, + total: total.toFixed(2), + items: items.map((i) => ({ title: i.title, variantTitle: i.variantTitle || undefined, quantity: i.quantity, price: i.price })), + shippingAddress, + }); + + res.json({ ok: true, orderNumber, orderId: order.id }); + }); + + // Stripe: create a PaymentIntent and return clientSecret to the frontend + app.post("/api/checkout/stripe/intent", async (req: Request, res: Response) => { + if (!isStripeConfigured()) return res.status(501).json({ error: "Stripe is not configured" }); + const { email } = req.body as { email?: string }; + if (!email) return res.status(400).json({ error: "Email required" }); + + const cart = await getOrCreateCart(req); + const items = await db.select().from(cartItems).where(eq(cartItems.cartId, cart.id)); + if (!items.length) return res.status(400).json({ error: "Cart is empty" }); + + const total = items.reduce((sum, i) => sum + parseFloat(i.price) * i.quantity, 0); + const result = await createPaymentIntent({ amountCents: toCents(total), email, metadata: { cartId: String(cart.id) } }); + if (!result.success) return res.status(500).json({ error: result.error }); + res.json({ clientSecret: result.clientSecret, paymentIntentId: result.paymentIntentId }); + }); + + // Stripe: confirm order after payment succeeded on client + app.post("/api/checkout/stripe/confirm", async (req: Request, res: Response) => { + const { paymentIntentId, email, shippingAddress, billingAddress, firstName, lastName } = req.body as { + paymentIntentId?: string; email?: string; shippingAddress?: any; billingAddress?: any; + firstName?: string; lastName?: string; + }; + if (!paymentIntentId || !email) return res.status(400).json({ error: "paymentIntentId and email required" }); + + const verify = await verifyPaymentIntent(paymentIntentId); + if (!verify.success) return res.status(402).json({ error: "Payment not confirmed" }); + + const cart = await getOrCreateCart(req); + const items = await db.select().from(cartItems).where(eq(cartItems.cartId, cart.id)); + if (!items.length) return res.status(400).json({ error: "Cart is empty" }); + + const subtotal = items.reduce((sum, i) => sum + parseFloat(i.price) * i.quantity, 0); + const total = subtotal; + const normalEmail = email.toLowerCase().trim(); + + let [customer] = await db.select().from(customers).where(eq(customers.email, normalEmail)); + if (!customer) { + [customer] = await db.insert(customers).values({ email: normalEmail, firstName, lastName }).returning(); + } + + const orderNumber = generateOrderNumber(); + const [order] = await db.insert(orders).values({ + orderNumber, + customerId: customer.id, + email: normalEmail, + status: "confirmed", + paymentStatus: "paid", + paymentProcessor: "stripe", + subtotal: subtotal.toFixed(2), + tax: "0.00", + shipping: "0.00", + total: total.toFixed(2), + stripePaymentIntentId: paymentIntentId, + shippingAddress, + billingAddress: billingAddress || shippingAddress, + paymentVerified: true, + }).returning(); + + await db.insert(orderItems).values(items.map((i) => ({ + orderId: order.id, productHandle: i.productHandle || undefined, variantId: i.variantId || undefined, + title: i.title, variantTitle: i.variantTitle || undefined, quantity: i.quantity, + price: i.price, total: (parseFloat(i.price) * i.quantity).toFixed(2), imageUrl: i.imageUrl || undefined, + }))); + + await db.update(carts).set({ status: "converted" }).where(eq(carts.id, cart.id)); + await sendOrderConfirmation({ email: normalEmail, orderNumber, total: total.toFixed(2), items, shippingAddress }); + + res.json({ ok: true, orderNumber, orderId: order.id }); + }); + + // ── Customer Account ────────────────────────────────────────────────────── + app.get("/api/account/orders", requireCustomer, async (req: Request, res: Response) => { + const customerId = (req.session as any).customerId; + const rows = await db.select().from(orders) + .where(eq(orders.customerId, customerId)) + .orderBy(desc(orders.createdAt)); + res.json(rows); + }); + + app.get("/api/account/orders/:id", requireCustomer, async (req: Request, res: Response) => { + const customerId = (req.session as any).customerId; + const [order] = await db.select().from(orders) + .where(and(eq(orders.id, parseInt(req.params.id)), eq(orders.customerId, customerId))); + if (!order) return res.status(404).json({ error: "Not found" }); + const items = await db.select().from(orderItems).where(eq(orderItems.orderId, order.id)); + res.json({ order, items }); + }); + + app.patch("/api/account/profile", requireCustomer, async (req: Request, res: Response) => { + const customerId = (req.session as any).customerId; + const { firstName, lastName, phone } = req.body as { firstName?: string; lastName?: string; phone?: string }; + const [updated] = await db.update(customers) + .set({ ...(firstName !== undefined && { firstName }), ...(lastName !== undefined && { lastName }), ...(phone !== undefined && { phone }) }) + .where(eq(customers.id, customerId)).returning(); + res.json(updated); + }); + + // ── Admin: Products ─────────────────────────────────────────────────────── + app.get("/api/admin/products", requireAdmin, async (req: Request, res: Response) => { + const { q, limit = "50", page = "1" } = req.query as Record; + const pageSize = parseInt(limit); + const offset = (parseInt(page) - 1) * pageSize; + const conditions = q ? [ilike(products.title, `%${q}%`)] : []; + const rows = await db.select().from(products).where(conditions.length ? and(...conditions) : undefined) + .orderBy(desc(products.createdAt)).limit(pageSize).offset(offset); + const [{ count }] = await db.select({ count: sql`count(*)` }).from(products) + .where(conditions.length ? and(...conditions) : undefined); + res.json({ products: rows, total: Number(count) }); + }); + + app.post("/api/admin/products", requireAdmin, async (req: Request, res: Response) => { + const [product] = await db.insert(products).values(req.body).returning(); + res.json(product); + }); + + app.patch("/api/admin/products/:id", requireAdmin, async (req: Request, res: Response) => { + const [product] = await db.update(products).set({ ...req.body, updatedAt: new Date() }) + .where(eq(products.id, parseInt(req.params.id))).returning(); + res.json(product); + }); + + app.delete("/api/admin/products/:id", requireAdmin, async (req: Request, res: Response) => { + await db.delete(products).where(eq(products.id, parseInt(req.params.id))); + res.json({ ok: true }); + }); + + // ── Admin: Orders ───────────────────────────────────────────────────────── + app.get("/api/admin/orders", requireAdmin, async (req: Request, res: Response) => { + const { status, q, limit = "50", page = "1" } = req.query as Record; + const pageSize = parseInt(limit); + const offset = (parseInt(page) - 1) * pageSize; + const conditions: any[] = []; + if (status) conditions.push(eq(orders.status, status)); + if (q) conditions.push(or(ilike(orders.orderNumber, `%${q}%`), ilike(orders.email, `%${q}%`))); + const rows = await db.select().from(orders).where(conditions.length ? and(...conditions) : undefined) + .orderBy(desc(orders.createdAt)).limit(pageSize).offset(offset); + const [{ count }] = await db.select({ count: sql`count(*)` }).from(orders) + .where(conditions.length ? and(...conditions) : undefined); + res.json({ orders: rows, total: Number(count) }); + }); + + app.get("/api/admin/orders/:id", requireAdmin, async (req: Request, res: Response) => { + const [order] = await db.select().from(orders).where(eq(orders.id, parseInt(req.params.id))); + if (!order) return res.status(404).json({ error: "Not found" }); + const items = await db.select().from(orderItems).where(eq(orderItems.orderId, order.id)); + const events = await db.select().from(orderEvents).where(eq(orderEvents.orderId, order.id)).orderBy(asc(orderEvents.createdAt)); + res.json({ order, items, events }); + }); + + app.patch("/api/admin/orders/:id", requireAdmin, async (req: Request, res: Response) => { + const { fulfillmentStatus, trackingNumber, trackingCarrier, status, notes } = req.body as { + fulfillmentStatus?: string; trackingNumber?: string; trackingCarrier?: string; status?: string; notes?: string; + }; + const updates: Record = { updatedAt: new Date() }; + if (fulfillmentStatus) updates.fulfillmentStatus = fulfillmentStatus; + if (trackingNumber) updates.trackingNumber = trackingNumber; + if (trackingCarrier) updates.trackingCarrier = trackingCarrier; + if (status) updates.status = status; + if (notes !== undefined) updates.notes = notes; + const [order] = await db.update(orders).set(updates).where(eq(orders.id, parseInt(req.params.id))).returning(); + if (trackingNumber && order.email) { + await sendShippingNotification({ email: order.email, orderNumber: order.orderNumber, trackingNumber, trackingCarrier }); + } + if (Object.keys(req.body).length > 0) { + await db.insert(orderEvents).values({ + orderId: order.id, type: "admin_update", + body: Object.entries(req.body).map(([k, v]) => `${k}: ${v}`).join(", "), actor: "admin", + }); + } + res.json(order); + }); + + app.post("/api/admin/orders/:id/refund", requireAdmin, async (req: Request, res: Response) => { + const [order] = await db.select().from(orders).where(eq(orders.id, parseInt(req.params.id))); + if (!order) return res.status(404).json({ error: "Not found" }); + const amountCents = req.body.amountCents ? parseInt(req.body.amountCents) : toCents(order.total); + + let result: { success: boolean; error?: string }; + if (order.authNetTransactionId) { + result = await refundTransaction({ + transactionId: order.authNetTransactionId, + amountCents, + cardLast4: req.body.cardLast4 || "0000", + }); + } else if (order.stripePaymentIntentId) { + result = await refundPaymentIntent({ paymentIntentId: order.stripePaymentIntentId, amountCents }); + } else { + return res.status(400).json({ error: "No transaction ID on record for this order" }); + } + + if (!result.success) return res.status(502).json({ error: result.error }); + await db.update(orders).set({ paymentStatus: "refunded", updatedAt: new Date() }).where(eq(orders.id, order.id)); + await db.insert(orderEvents).values({ + orderId: order.id, type: "refunded", + body: `Refunded $${(amountCents / 100).toFixed(2)}`, actor: "admin", + }); + res.json({ ok: true }); + }); + + // ── Admin: Customers ────────────────────────────────────────────────────── + app.get("/api/admin/customers", requireAdmin, async (req: Request, res: Response) => { + const { q, limit = "50", page = "1" } = req.query as Record; + const pageSize = parseInt(limit); + const offset = (parseInt(page) - 1) * pageSize; + const conditions = q + ? [or(ilike(customers.email, `%${q}%`), ilike(customers.firstName, `%${q}%`), ilike(customers.lastName, `%${q}%`))] + : []; + const rows = await db.select().from(customers).where(conditions.length ? and(...conditions) : undefined) + .orderBy(desc(customers.createdAt)).limit(pageSize).offset(offset); + res.json(rows); + }); + + // ── Admin: Discounts ────────────────────────────────────────────────────── + app.get("/api/admin/discounts", requireAdmin, async (_req, res) => { + const rows = await db.select().from(discountCodes).orderBy(desc(discountCodes.createdAt)); + res.json(rows); + }); + + app.post("/api/admin/discounts", requireAdmin, async (req: Request, res: Response) => { + const [code] = await db.insert(discountCodes).values({ + ...req.body, + code: req.body.code?.toUpperCase(), + }).returning(); + res.json(code); + }); + + app.patch("/api/admin/discounts/:id", requireAdmin, async (req: Request, res: Response) => { + const [code] = await db.update(discountCodes).set(req.body).where(eq(discountCodes.id, parseInt(req.params.id))).returning(); + res.json(code); + }); + + app.delete("/api/admin/discounts/:id", requireAdmin, async (req: Request, res: Response) => { + await db.delete(discountCodes).where(eq(discountCodes.id, parseInt(req.params.id))); + res.json({ ok: true }); + }); + + // ── Admin: Dashboard Stats ──────────────────────────────────────────────── + app.get("/api/admin/stats", requireAdmin, async (_req, res) => { + const [orderStats] = await db.select({ + totalOrders: sql`count(*)`, + totalRevenue: sql`coalesce(sum(cast(total as numeric)), 0)`, + }).from(orders).where(eq(orders.paymentStatus, "paid")); + + const [customerCount] = await db.select({ count: sql`count(*)` }).from(customers); + const [productCount] = await db.select({ count: sql`count(*)` }).from(products).where(eq(products.status, "active")); + + const recentOrders = await db.select().from(orders).orderBy(desc(orders.createdAt)).limit(5); + + res.json({ + totalOrders: Number(orderStats.totalOrders), + totalRevenue: Number(orderStats.totalRevenue), + totalCustomers: Number(customerCount.count), + activeProducts: Number(productCount.count), + recentOrders, + }); + }); +}