640 lines
29 KiB
TypeScript
640 lines
29 KiB
TypeScript
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<string, string>;
|
|
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<number>`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<string, string>;
|
|
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<number>`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<string, string>;
|
|
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<number>`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<string, any> = { 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<string, string>;
|
|
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<number>`count(*)`,
|
|
totalRevenue: sql<number>`coalesce(sum(cast(total as numeric)), 0)`,
|
|
}).from(orders).where(eq(orders.paymentStatus, "paid"));
|
|
|
|
const [customerCount] = await db.select({ count: sql<number>`count(*)` }).from(customers);
|
|
const [productCount] = await db.select({ count: sql<number>`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,
|
|
});
|
|
});
|
|
}
|