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, }); }); }