Skip to main content

Version 5: Admin & Features

Focus: Admin Panel, OAuth, Search, Email, Inventory


Overview

Version 5 transforms the MVP into a full-featured platform with comprehensive admin tools, social login, search, email notifications, and inventory management.


5A - Admin Panel

Objective

Product, order, and category management interface.

Routes Structure

/admin
├── /products → Product CRUD
├── /categories → Category management
├── /orders → Order list + status
└── /inventory → Stock management

Product Management

List Products:

export async function GET() {
const session = await auth();
if (session?.user?.role !== "admin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}

const result = await query(`
SELECT p.*, c.name as category_name
FROM products p
LEFT JOIN categories c ON p.category_id = c.id
ORDER BY p.created_at DESC
`);

return NextResponse.json({ products: result.rows });
}

Create Product:

export async function POST(req: Request) {
const session = await auth();
if (session?.user?.role !== "admin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}

const { name, price, description, imageUrl, categoryId } = await req.json();

// Validation
if (!name || !price || price <= 0) {
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}

const result = await query(
`INSERT INTO products (name, price, description, image_url, category_id)
VALUES ($1, $2, $3, $4, $5) RETURNING *`,
[name, price, description, imageUrl, categoryId]
);

// Invalidate cache
await redis.del(CACHE_KEYS.PRODUCTS_ALL);

return NextResponse.json({ product: result.rows[0] }, { status: 201 });
}

Image Upload (Cloudinary)

import cloudinary from "@/lib/cloudinary";

export async function POST(req: Request) {
const formData = await req.formData();
const file = formData.get("file") as File;

const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);

const result = await new Promise((resolve, reject) => {
cloudinary.uploader
.upload_stream({ folder: "mountify/products" }, (error, result) => {
if (error) reject(error);
else resolve(result);
})
.end(buffer);
});

return NextResponse.json({
url: result.secure_url,
publicId: result.public_id,
});
}

Cache Invalidation

// After any admin CRUD operation
await redis.del(CACHE_KEYS.PRODUCTS_ALL);
await redis.del(CACHE_KEYS.CATEGORIES_ALL);

5B - Google OAuth

Objective

One-click social login.

Configuration

import Google from "next-auth/providers/google";

export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [
Google({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),
Credentials({ ... }),
],

callbacks: {
async jwt({ token, user, account }) {
// Credentials login
if (user) {
token.id = user.id;
token.role = user.role;
}

// Google OAuth: Auto-create user
if (account?.provider === "google") {
const email = token.email;
if (!email) return token;

// Upsert: Create new or return existing
const res = await query(`
INSERT INTO users (email, password_hash, role)
VALUES ($1, NULL, 'customer')
ON CONFLICT (email)
DO UPDATE SET email = EXCLUDED.email
RETURNING id, role
`, [email]);

token.id = res.rows[0].id.toString();
token.role = res.rows[0].role;
}

return token;
},
},
});

OAuth User Behavior

AspectBehavior
First loginAuto-create user with password_hash = NULL
Subsequent loginReturn existing user
Password loginBlocked (no password set)
RoleDefault customer

Objective

Product search with filters.

URL-Based State

/products?category=mounts&sort=price_asc&page=2&search=phone

API Implementation

export async function GET(req: NextRequest) {
const searchParams = req.nextUrl.searchParams;

const category = searchParams.get("category");
const search = searchParams.get("search");
const sort = searchParams.get("sort") || "newest";
const page = parseInt(searchParams.get("page") || "1");
const limit = 12;

// Build dynamic WHERE
const conditions: string[] = [];
const params: any[] = [];
let paramIndex = 1;

if (category && category !== "all") {
conditions.push(`c.slug = $${paramIndex}`);
params.push(category);
paramIndex++;
}

if (search && search.length >= 2) {
conditions.push(
`(p.name ILIKE $${paramIndex} OR p.description ILIKE $${paramIndex})`
);
params.push(`%${search}%`);
paramIndex++;
}

const whereClause =
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";

// Sort mapping
const orderMap = {
newest: "p.created_at DESC",
price_asc: "p.price ASC",
price_desc: "p.price DESC",
name: "p.name ASC",
};

// Query
const result = await query(
`
SELECT p.*, c.name as category_name
FROM products p
LEFT JOIN categories c ON p.category_id = c.id
${whereClause}
ORDER BY ${orderMap[sort]}
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
`,
[...params, limit, (page - 1) * limit]
);

return NextResponse.json({
products: result.rows,
pagination: { page, limit, total, totalPages },
});
}

Cache Strategy

  • No filters: Use cache
  • With filters: Skip cache, query DB directly

5D - Email Notifications

Objective

Transactional emails via Resend.

Setup

import { Resend } from "resend";

const resend = new Resend(process.env.RESEND_API_KEY);

export async function sendOrderConfirmationEmail({
orderId,
email,
total,
items,
}: OrderEmailParams) {
await resend.emails.send({
from: "Mountify <orders@mountify.com>",
to: email,
subject: `Order Confirmation #${orderId}`,
html: `
<h1>Thank you for your order!</h1>
<p>Order #${orderId}</p>
<h2>Items:</h2>
<ul>
${items
.map(
(item) =>
`<li>${item.name} x ${item.quantity} - $${item.price}</li>`
)
.join("")}
</ul>
<p><strong>Total: $${total.toFixed(2)}</strong></p>
`,
});
}

Email Types

EmailTrigger
Order ConfirmationWebhook: payment_status = paid
Shipping NotificationAdmin clicks "Ship Order"
Password ResetUser requests reset

5E - Password Reset

Objective

Secure password recovery flow.

Flow

1. User enters email → POST /api/auth/forgot-password
2. Generate secure token → Hash with SHA-256 → Store hash in DB
3. Send email with plain token
4. User clicks link → POST /api/auth/reset-password
5. Hash submitted token → Compare with DB → Update password

Security Features

// Generate token
const token = crypto.randomBytes(32).toString("hex");
const tokenHash = crypto.createHash("sha256").update(token).digest("hex");

// Store only hash
await query(
`
INSERT INTO password_reset_tokens (user_id, token_hash, expires_at)
VALUES ($1, $2, NOW() + INTERVAL '1 hour')
ON CONFLICT (user_id) DO UPDATE SET ...
`,
[userId, tokenHash]
);

// Atomic consumption
const result = await query(
`
UPDATE password_reset_tokens
SET used = TRUE
WHERE token_hash = $1 AND used = FALSE AND expires_at > NOW()
RETURNING user_id
`,
[tokenHash]
);
Security PropertyImplementation
Token hashingSHA-256, only hash stored
One-time useused = TRUE on consumption
Expiration1 hour TTL
Rate limiting3 requests/email/15min
Email enumerationSame response regardless

5F - Address Management

Objective

Save and reuse shipping addresses.

Schema

CREATE TABLE addresses (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
line1 TEXT NOT NULL,
line2 TEXT,
city TEXT NOT NULL,
state TEXT,
postal_code TEXT NOT NULL,
country TEXT NOT NULL,
phone TEXT,
is_default BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT now()
);

-- One default per user
CREATE UNIQUE INDEX uq_addresses_one_default_per_user
ON addresses(user_id) WHERE is_default = true;

-- Prevent duplicates
CREATE UNIQUE INDEX uq_addresses_dedupe
ON addresses(user_id, line1, postal_code);

Auto-Save from Checkout

// In webhook handler, after payment success
if (userId && session.customer_details?.address) {
await query(
`
INSERT INTO addresses (user_id, name, line1, city, postal_code, country, is_default)
VALUES ($1, $2, $3, $4, $5, $6, NOT EXISTS (SELECT 1 FROM addresses WHERE user_id = $1))
ON CONFLICT (user_id, line1, postal_code) DO NOTHING
`,
[userId, ...addressFields]
);
}

5G - Inventory Management

Objective

Track stock with reservation system.

Schema

CREATE TABLE inventory (
sku_id BIGINT PRIMARY KEY REFERENCES products(id),
on_hand INTEGER NOT NULL DEFAULT 0 CHECK (on_hand >= 0),
reserved INTEGER NOT NULL DEFAULT 0 CHECK (reserved >= 0),
updated_at TIMESTAMPTZ DEFAULT now()
);

Stock Model

on_hand:   Total physical stock
reserved: Locked during checkout
available: on_hand - reserved

Reservation Flow

// 1. Reserve (checkout start)
const result = await query(
`
UPDATE inventory
SET reserved = reserved + $1
WHERE sku_id = $2 AND (on_hand - reserved) >= $1
RETURNING sku_id
`,
[quantity, productId]
);

if (result.rowCount === 0) {
throw new Error("Insufficient stock");
}

// 2. Deduct (payment success)
await query(
`
UPDATE inventory
SET on_hand = on_hand - $1, reserved = reserved - $1
WHERE sku_id = $2
`,
[quantity, productId]
);

// 3. Release (payment failed/expired)
await query(
`
UPDATE inventory
SET reserved = reserved - $1
WHERE sku_id = $2
`,
[quantity, productId]
);

Objective

Multi-image product display.

Schema

CREATE TABLE product_images (
id SERIAL PRIMARY KEY,
product_id INTEGER REFERENCES products(id) ON DELETE CASCADE,
url TEXT NOT NULL,
public_id TEXT NOT NULL,
display_order INTEGER DEFAULT 0,
is_primary BOOLEAN DEFAULT FALSE
);
export default function ImageGallery({ images, productName }) {
const [activeIndex, setActiveIndex] = useState(0);

return (
<div>
{/* Main image */}
<div className="aspect-square relative">
<Image
src={images[activeIndex].url}
alt={productName}
fill
className="object-cover"
/>
</div>

{/* Thumbnails */}
<div className="flex gap-2 mt-4">
{images.map((img, i) => (
<button
key={img.id}
onClick={() => setActiveIndex(i)}
className={`w-16 h-16 rounded-lg overflow-hidden
${i === activeIndex ? "ring-2 ring-black" : ""}`}
>
<Image src={img.url} alt="" fill className="object-cover" />
</button>
))}
</div>
</div>
);
}

Files Created

FilePurpose
src/app/admin/products/page.tsxProduct management
src/app/admin/orders/page.tsxOrder management
src/app/admin/categories/page.tsxCategory management
src/app/api/admin/*Admin API routes
src/lib/email.tsEmail helpers
src/lib/cloudinary.tsImage upload
src/app/api/auth/forgot-password/route.tsPassword reset request
src/app/api/auth/reset-password/route.tsPassword reset execution
src/app/api/user/addresses/route.tsAddress CRUD
src/app/api/inventory/route.tsStock queries

Feature Summary

FeatureStatus
Admin product CRUD
Admin order management
Admin category management
Google OAuth
Product search
Filter + sort + pagination
Order confirmation email
Password reset email
Address management
Inventory tracking
Stock reservation
Image gallery

Next Version Preview

Version 6.0 will add shipping integration with 17track API and shipping notification emails.