733 lines
24 KiB
JavaScript

import { pbkdf2Sync, randomBytes, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";
import { db } from "./db.js";
const PORT = Number(process.env.API_PORT || 4174);
const SESSION_DAYS = 30;
const now = () => new Date().toISOString();
const addDays = (days) => new Date(Date.now() + days * 86400000).toISOString();
const moneyId = (value) => value;
const json = (res, status, payload) => {
res.writeHead(status, {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET,POST,PATCH,DELETE,OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
});
res.end(JSON.stringify(payload));
};
const readBody = async (req) =>
new Promise((resolve, reject) => {
let raw = "";
req.on("data", (chunk) => {
raw += chunk;
if (raw.length > 1_000_000) {
reject(new Error("Request body too large"));
req.destroy();
}
});
req.on("end", () => {
if (!raw) {
resolve({});
return;
}
try {
resolve(JSON.parse(raw));
} catch {
reject(new Error("Invalid JSON"));
}
});
req.on("error", reject);
});
const hashPassword = (password, salt = randomBytes(16).toString("hex")) => ({
salt,
hash: pbkdf2Sync(password, salt, 120000, 64, "sha512").toString("hex"),
});
const verifyPassword = (password, salt, expectedHash) => {
const { hash } = hashPassword(password, salt);
const actual = Buffer.from(hash, "hex");
const expected = Buffer.from(expectedHash, "hex");
return actual.length === expected.length && timingSafeEqual(actual, expected);
};
const getBearerToken = (req) => {
const auth = req.headers.authorization || "";
const match = auth.match(/^Bearer\s+(.+)$/i);
return match ? match[1] : null;
};
const composeAddress = ({ street_name, house_number, zip_code, city }) =>
[street_name, house_number].filter(Boolean).join(" ").trim() +
(zip_code || city ? `, ${[zip_code, city].filter(Boolean).join(" ")}` : "");
const normalizeEmail = (email) => String(email || "").trim().toLowerCase();
const notificationForUser = (userId) => {
const existing = db
.prepare("SELECT * FROM notification_preferences WHERE user_id = ?")
.get(userId);
if (existing) return existing;
db.prepare(
`INSERT INTO notification_preferences (
user_id, drops_enabled, restocks_enabled, small_batch_enabled, discovery_enabled, updated_at
) VALUES (?, 0, 0, 0, 0, ?)`
).run(userId, now());
return db.prepare("SELECT * FROM notification_preferences WHERE user_id = ?").get(userId);
};
const rowToUser = (row) => {
if (!row) return null;
return {
id: row.id,
email: row.email,
name: row.name,
first_name: row.first_name,
surname: row.surname,
address: row.address,
street_name: row.street_name || "",
house_number: row.house_number || "",
zip_code: row.zip_code || "",
city: row.city || "",
birthdate: row.birthdate || "",
created_at: row.created_at,
notifications: prefsToJson(notificationForUser(row.id)),
productSubscriptions: getProductSubscriptions(row.id),
discoveryStatus: getDiscoveryStatus(row.id),
sampleCredits: getSampleCreditStatus(row.id),
loyaltyStatus: getLoyaltyStatus(row.id),
};
};
const prefsToJson = (prefs) => ({
drops_enabled: Boolean(prefs.drops_enabled),
restocks_enabled: Boolean(prefs.restocks_enabled),
small_batch_enabled: Boolean(prefs.small_batch_enabled),
discovery_enabled: Boolean(prefs.discovery_enabled),
updated_at: prefs.updated_at,
});
const authenticate = (req) => {
const token = getBearerToken(req);
if (!token) return null;
const session = db
.prepare("SELECT * FROM sessions WHERE token = ?")
.get(token);
if (!session) return null;
if (new Date(session.expires_at).getTime() <= Date.now()) {
db.prepare("DELETE FROM sessions WHERE token = ?").run(token);
return null;
}
const user = db.prepare("SELECT * FROM users WHERE id = ?").get(session.user_id);
return user ? { token, user } : null;
};
const requireAuth = (req, res) => {
const auth = authenticate(req);
if (!auth) {
json(res, 401, { error: "Please log in to continue." });
return null;
}
return auth;
};
const createSession = (userId) => {
const token = randomBytes(32).toString("hex");
db.prepare(
"INSERT INTO sessions (token, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)"
).run(token, userId, now(), addDays(SESSION_DAYS));
return token;
};
const getCartRows = (userId) =>
db.prepare(
`SELECT c.product_id, c.quantity, p.slug, p.name, p.kind, p.size_label,
p.price_cents, p.discovery_credit_cents
FROM cart_items c
JOIN products p ON p.id = c.product_id
WHERE c.user_id = ?
ORDER BY c.created_at ASC`
).all(userId);
const getAvailableDiscounts = (userId, rows) => {
const discounts = [];
const fullRows = rows.filter((row) => row.kind === "full_size");
const fullTotal = fullRows.reduce(
(sum, row) => sum + row.price_cents * row.quantity,
0
);
if (fullTotal > 0) {
const discovery = db
.prepare(
`SELECT * FROM discovery_credits
WHERE user_id = ? AND redeemed_order_id IS NULL
ORDER BY id ASC LIMIT 1`
)
.get(userId);
if (discovery) {
discounts.push({
type: "discovery",
creditId: discovery.id,
amount_cents: Math.min(discovery.amount_cents, fullTotal),
label: "Discovery Set credit",
});
}
for (const row of fullRows) {
const sample = db
.prepare(
`SELECT * FROM sample_credits
WHERE user_id = ? AND slug = ? AND redeemed_order_id IS NULL
ORDER BY id ASC LIMIT 1`
)
.get(userId, row.slug);
if (sample) {
discounts.push({
type: "sample",
creditId: sample.id,
slug: row.slug,
product_id: row.product_id,
amount_cents: Math.min(sample.amount_cents, row.price_cents * row.quantity),
label: `${row.name.replace(" Full Size", "")} sample credit`,
});
}
}
}
return discounts.filter((discount) => discount.amount_cents > 0);
};
const getCart = (userId) => {
const rows = getCartRows(userId);
const items = rows.map((row) => ({
product_id: row.product_id,
quantity: row.quantity,
line_total_cents: row.price_cents * row.quantity,
product: {
id: row.product_id,
slug: row.slug,
name: row.name,
kind: row.kind,
size_label: row.size_label,
price_cents: row.price_cents,
discovery_credit_cents: row.discovery_credit_cents,
},
}));
const subtotal_cents = items.reduce((sum, item) => sum + item.line_total_cents, 0);
const discounts = getAvailableDiscounts(userId, rows);
const discount_cents = discounts.reduce((sum, item) => sum + item.amount_cents, 0);
return {
items,
subtotal_cents,
discount_cents,
total_cents: Math.max(0, subtotal_cents - discount_cents),
total_quantity: items.reduce((sum, item) => sum + item.quantity, 0),
discounts,
};
};
const getOrders = (userId) => {
const orders = db
.prepare("SELECT * FROM orders WHERE user_id = ? ORDER BY created_at DESC")
.all(userId);
const itemStmt = db.prepare(
`SELECT oi.*, p.name, p.slug, p.kind, p.size_label
FROM order_items oi
JOIN products p ON p.id = oi.product_id
WHERE oi.order_id = ?
ORDER BY oi.id ASC`
);
return orders.map((order) => ({
...order,
items: itemStmt.all(order.id).map((item) => ({
id: item.id,
product_id: item.product_id,
quantity: item.quantity,
unit_price_cents: item.unit_price_cents,
line_total_cents: item.line_total_cents,
product: {
id: item.product_id,
name: item.name,
slug: item.slug,
kind: item.kind,
size_label: item.size_label,
},
})),
}));
};
function getDiscoveryStatus(userId) {
const credit = db
.prepare("SELECT * FROM discovery_credits WHERE user_id = ? ORDER BY id ASC LIMIT 1")
.get(userId);
if (!credit) return "No Discount atm";
return credit.redeemed_order_id ? "Discount already used" : "Discount available";
}
function getSampleCreditStatus(userId) {
return db
.prepare(
`SELECT slug, amount_cents, redeemed_order_id, created_at, redeemed_at
FROM sample_credits
WHERE user_id = ?
ORDER BY created_at DESC`
)
.all(userId)
.map((credit) => ({
...credit,
status: credit.redeemed_order_id ? "used" : "available",
}));
}
function getProductSubscriptions(userId) {
return db
.prepare(
`SELECT ps.id, ps.product_id, ps.type, ps.created_at,
p.slug, p.name, p.kind, p.size_label
FROM product_subscriptions ps
JOIN products p ON p.id = ps.product_id
WHERE ps.user_id = ?
ORDER BY ps.created_at DESC, ps.id DESC`
)
.all(userId);
}
function getLoyaltyStatus(userId) {
const orderStats = db
.prepare(
`SELECT COUNT(*) AS purchases, COALESCE(SUM(total_cents), 0) AS spent_cents
FROM orders
WHERE user_id = ?`
)
.get(userId);
const discovery = db
.prepare(
`SELECT 1
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
WHERE o.user_id = ? AND oi.product_id = 'discovery-set'
LIMIT 1`
)
.get(userId);
const full = db
.prepare(
`SELECT 1
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
JOIN products p ON p.id = oi.product_id
WHERE o.user_id = ? AND p.kind = 'full_size'
LIMIT 1`
)
.get(userId);
const status = {
hasDiscoverySet: Boolean(discovery),
hasFullSize: Boolean(full),
purchases: Number(orderStats.purchases || 0),
spent_cents: Number(orderStats.spent_cents || 0),
};
status.unlocked =
status.hasDiscoverySet &&
status.hasFullSize &&
status.purchases >= 3 &&
status.spent_cents > 50000;
return status;
}
const stateForUser = (userRow, token) => ({
token,
user: rowToUser(userRow),
cart: getCart(userRow.id),
orders: getOrders(userRow.id),
});
const register = async (req, res) => {
const body = await readBody(req);
const email = normalizeEmail(body.email);
const password = String(body.password || "");
const firstName = String(body.first_name || body.firstName || "").trim();
const surname = String(body.surname || "").trim();
if (!firstName || !surname || !email || !password) {
json(res, 400, { error: "First name, surname, email, and password are required." });
return;
}
const existing = db.prepare("SELECT id FROM users WHERE email = ?").get(email);
if (existing) {
json(res, 409, { error: "An account with this email already exists." });
return;
}
const { salt, hash } = hashPassword(password);
const fullName = `${firstName} ${surname}`.trim();
const created = now();
const result = db
.prepare(
`INSERT INTO users (
email, password_hash, password_salt, name, first_name, surname,
address, street_name, house_number, zip_code, city, birthdate, created_at
) VALUES (?, ?, ?, ?, ?, ?, '', '', '', '', '', '', ?)`
)
.run(email, hash, salt, fullName, firstName, surname, created);
notificationForUser(result.lastInsertRowid);
const user = db.prepare("SELECT * FROM users WHERE id = ?").get(result.lastInsertRowid);
json(res, 201, stateForUser(user, createSession(user.id)));
};
const login = async (req, res) => {
const body = await readBody(req);
const email = normalizeEmail(body.email);
const password = String(body.password || "");
const user = db.prepare("SELECT * FROM users WHERE email = ?").get(email);
if (!user || !verifyPassword(password, user.password_salt, user.password_hash)) {
json(res, 401, { error: "Invalid email or password." });
return;
}
json(res, 200, stateForUser(user, createSession(user.id)));
};
const patchProfile = async (req, res, user) => {
const body = await readBody(req);
const current = db.prepare("SELECT * FROM users WHERE id = ?").get(user.id);
const firstName = String(body.first_name ?? current.first_name ?? "").trim();
const surname = String(body.surname ?? current.surname ?? "").trim();
const streetName = String(body.street_name ?? current.street_name ?? "").trim();
const houseNumber = String(body.house_number ?? current.house_number ?? "").trim();
const zipCode = String(body.zip_code ?? current.zip_code ?? "").trim();
const city = String(body.city ?? current.city ?? "").trim();
const birthdate = String(body.birthdate ?? current.birthdate ?? "").trim();
const name = `${firstName} ${surname}`.trim();
const address = composeAddress({
street_name: streetName,
house_number: houseNumber,
zip_code: zipCode,
city,
});
if (!firstName || !surname) {
json(res, 400, { error: "First name and surname are required." });
return;
}
db.prepare(
`UPDATE users
SET name = ?, first_name = ?, surname = ?, address = ?, street_name = ?,
house_number = ?, zip_code = ?, city = ?, birthdate = ?
WHERE id = ?`
).run(name, firstName, surname, address, streetName, houseNumber, zipCode, city, birthdate, user.id);
const updated = db.prepare("SELECT * FROM users WHERE id = ?").get(user.id);
json(res, 200, { user: rowToUser(updated) });
};
const patchNotifications = async (req, res, user) => {
const body = await readBody(req);
db.prepare(
`INSERT INTO notification_preferences (
user_id, drops_enabled, restocks_enabled, small_batch_enabled, discovery_enabled, updated_at
) VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
drops_enabled = excluded.drops_enabled,
restocks_enabled = excluded.restocks_enabled,
small_batch_enabled = excluded.small_batch_enabled,
discovery_enabled = excluded.discovery_enabled,
updated_at = excluded.updated_at`
).run(
user.id,
body.drops_enabled ? 1 : 0,
body.restocks_enabled ? 1 : 0,
body.small_batch_enabled ? 1 : 0,
body.discovery_enabled ? 1 : 0,
now()
);
json(res, 200, { notifications: prefsToJson(notificationForUser(user.id)) });
};
const addCartItem = async (req, res, user) => {
const body = await readBody(req);
const productId = moneyId(String(body.productId || body.product_id || ""));
const quantity = Math.max(1, Number(body.quantity || 1));
const product = db.prepare("SELECT * FROM products WHERE id = ?").get(productId);
if (!product) {
json(res, 404, { error: "Product not found." });
return;
}
const timestamp = now();
db.prepare(
`INSERT INTO cart_items (user_id, product_id, quantity, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(user_id, product_id) DO UPDATE SET
quantity = cart_items.quantity + excluded.quantity,
updated_at = excluded.updated_at`
).run(user.id, productId, quantity, timestamp, timestamp);
json(res, 200, {
cart: getCart(user.id),
message: `${quantity} x ${product.name} added.`,
});
};
const patchCartItem = async (req, res, user, productId) => {
const body = await readBody(req);
const quantity = Number(body.quantity);
if (!Number.isFinite(quantity)) {
json(res, 400, { error: "Quantity is required." });
return;
}
if (quantity <= 0) {
db.prepare("DELETE FROM cart_items WHERE user_id = ? AND product_id = ?").run(user.id, productId);
} else {
db.prepare(
"UPDATE cart_items SET quantity = ?, updated_at = ? WHERE user_id = ? AND product_id = ?"
).run(Math.floor(quantity), now(), user.id, productId);
}
json(res, 200, { cart: getCart(user.id) });
};
const deleteCartItem = (res, user, productId) => {
db.prepare("DELETE FROM cart_items WHERE user_id = ? AND product_id = ?").run(user.id, productId);
json(res, 200, { cart: getCart(user.id) });
};
const checkout = async (req, res, user) => {
const body = await readBody(req);
const rows = getCartRows(user.id);
if (rows.length === 0) {
json(res, 400, { error: "Your cart is empty." });
return;
}
const addressFields = {
street_name: String(body.street_name || "").trim(),
house_number: String(body.house_number || "").trim(),
zip_code: String(body.zip_code || "").trim(),
city: String(body.city || "").trim(),
};
const paymentMethod = String(body.payment_method || body.paymentMethod || "").trim();
if (!addressFields.street_name || !addressFields.house_number || !addressFields.zip_code || !addressFields.city) {
json(res, 400, { error: "Street name, house number, ZIP code, and city are required." });
return;
}
if (!["Bill", "Card", "Twint", "PayPal"].includes(paymentMethod)) {
json(res, 400, { error: "Choose a payment method." });
return;
}
const subtotal = rows.reduce((sum, row) => sum + row.price_cents * row.quantity, 0);
const discounts = getAvailableDiscounts(user.id, rows);
const discountTotal = discounts.reduce((sum, discount) => sum + discount.amount_cents, 0);
const total = Math.max(0, subtotal - discountTotal);
const shippingAddress = composeAddress(addressFields);
const timestamp = now();
try {
db.exec("BEGIN");
const orderResult = db
.prepare(
`INSERT INTO orders (
user_id, subtotal_cents, discount_cents, total_cents,
shipping_address, payment_method, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?)`
)
.run(user.id, subtotal, discountTotal, total, shippingAddress, paymentMethod, timestamp);
const orderId = orderResult.lastInsertRowid;
const insertItem = db.prepare(
`INSERT INTO order_items (
order_id, product_id, quantity, unit_price_cents, line_total_cents
) VALUES (?, ?, ?, ?, ?)`
);
for (const row of rows) {
insertItem.run(orderId, row.product_id, row.quantity, row.price_cents, row.price_cents * row.quantity);
}
for (const discount of discounts) {
if (discount.type === "discovery") {
db.prepare(
"UPDATE discovery_credits SET redeemed_order_id = ?, redeemed_at = ? WHERE id = ?"
).run(orderId, timestamp, discount.creditId);
}
if (discount.type === "sample") {
db.prepare(
"UPDATE sample_credits SET redeemed_order_id = ?, redeemed_at = ? WHERE id = ?"
).run(orderId, timestamp, discount.creditId);
}
}
const hasDiscoverySet = rows.some((row) => row.product_id === "discovery-set");
const hadDiscoveryCredit = db
.prepare("SELECT id FROM discovery_credits WHERE user_id = ? LIMIT 1")
.get(user.id);
if (hasDiscoverySet && !hadDiscoveryCredit) {
db.prepare(
`INSERT INTO discovery_credits (
user_id, order_id, amount_cents, redeemed_order_id, created_at, redeemed_at
) VALUES (?, ?, 4800, NULL, ?, NULL)`
).run(user.id, orderId, timestamp);
}
const sampleRows = rows.filter((row) => row.kind === "sample");
for (const row of sampleRows) {
const existing = db
.prepare("SELECT id FROM sample_credits WHERE user_id = ? AND slug = ? LIMIT 1")
.get(user.id, row.slug);
if (!existing) {
db.prepare(
`INSERT INTO sample_credits (
user_id, slug, order_id, amount_cents, redeemed_order_id, created_at, redeemed_at
) VALUES (?, ?, ?, ?, NULL, ?, NULL)`
).run(user.id, row.slug, orderId, row.price_cents, timestamp);
}
}
db.prepare("DELETE FROM cart_items WHERE user_id = ?").run(user.id);
db.exec("COMMIT");
const updatedUser = db.prepare("SELECT * FROM users WHERE id = ?").get(user.id);
json(res, 200, {
order: getOrders(user.id).find((order) => order.id === orderId),
cart: getCart(user.id),
orders: getOrders(user.id),
user: rowToUser(updatedUser),
});
} catch (error) {
db.exec("ROLLBACK");
throw error;
}
};
const subscribeProduct = async (req, res, user) => {
const body = await readBody(req);
const productId = String(body.product_id || body.productId || "").trim();
const type = String(body.type || "restock").trim();
const product = db.prepare("SELECT * FROM products WHERE id = ?").get(productId);
if (!product) {
json(res, 404, { error: "Product not found." });
return;
}
db.prepare(
`INSERT OR IGNORE INTO product_subscriptions (user_id, product_id, type, created_at)
VALUES (?, ?, ?, ?)`
).run(user.id, productId, type, now());
json(res, 200, {
ok: true,
message: `${product.name} ${type} subscription saved.`,
subscriptions: getProductSubscriptions(user.id),
});
};
const deleteProductSubscription = (res, user, id) => {
db.prepare("DELETE FROM product_subscriptions WHERE id = ? AND user_id = ?").run(
id,
user.id
);
json(res, 200, {
ok: true,
subscriptions: getProductSubscriptions(user.id),
});
};
const smallBatch = (res, user) => {
const loyaltyStatus = getLoyaltyStatus(user.id);
const releases = loyaltyStatus.unlocked
? [
{
type: "Archive Batch",
name: "KALTER BETON Archive Batch 01",
note: "A colder iris-heavy return from the first concrete accord trials.",
},
{
type: "Prototype",
name: "NASSER MARMOR Fog Prototype",
note: "A misted marble study with softened aldehydes and mineral musk.",
},
{
type: "Small Batch",
name: "SCHWARZES BENZIN Night Run",
note: "Low-light petrol, birch smoke, and leather in a numbered run.",
},
]
: [];
json(res, 200, { loyaltyStatus, releases });
};
const route = async (req, res) => {
if (req.method === "OPTIONS") {
json(res, 204, {});
return;
}
const url = new URL(req.url, `http://${req.headers.host}`);
const path = url.pathname;
if (req.method === "GET" && path === "/api/catalog") {
json(res, 200, { products: db.prepare("SELECT * FROM products ORDER BY id ASC").all() });
return;
}
if (req.method === "POST" && path === "/api/auth/register") return register(req, res);
if (req.method === "POST" && path === "/api/auth/login") return login(req, res);
if (req.method === "POST" && path === "/api/auth/logout") {
const token = getBearerToken(req);
if (token) db.prepare("DELETE FROM sessions WHERE token = ?").run(token);
json(res, 200, { ok: true });
return;
}
if (req.method === "GET" && path === "/api/auth/session") {
const auth = requireAuth(req, res);
if (!auth) return;
json(res, 200, stateForUser(auth.user, auth.token));
return;
}
const auth = requireAuth(req, res);
if (!auth) return;
const { user } = auth;
if (req.method === "PATCH" && path === "/api/profile") return patchProfile(req, res, user);
if (req.method === "PATCH" && path === "/api/notifications") return patchNotifications(req, res, user);
if (req.method === "GET" && path === "/api/cart") {
json(res, 200, { cart: getCart(user.id) });
return;
}
if (req.method === "POST" && path === "/api/cart/items") return addCartItem(req, res, user);
if (req.method === "POST" && path === "/api/cart/checkout") return checkout(req, res, user);
if (req.method === "POST" && path === "/api/product-subscriptions") return subscribeProduct(req, res, user);
if (req.method === "GET" && path === "/api/small-batch") return smallBatch(res, user);
const subscriptionMatch = path.match(/^\/api\/product-subscriptions\/(\d+)$/);
if (subscriptionMatch && req.method === "DELETE") {
return deleteProductSubscription(res, user, Number(subscriptionMatch[1]));
}
const itemMatch = path.match(/^\/api\/cart\/items\/([^/]+)$/);
if (itemMatch && req.method === "PATCH") {
return patchCartItem(req, res, user, decodeURIComponent(itemMatch[1]));
}
if (itemMatch && req.method === "DELETE") {
return deleteCartItem(res, user, decodeURIComponent(itemMatch[1]));
}
json(res, 404, { error: "Route not found." });
};
createServer((req, res) => {
route(req, res).catch((error) => {
console.error(error);
json(res, 500, { error: "Server error." });
});
}).listen(PORT, () => {
console.log(`Shop API listening on http://localhost:${PORT}`);
});