733 lines
24 KiB
JavaScript
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}`);
|
|
});
|