Initial content

This commit is contained in:
Adam Freeman
2024-05-29 18:54:49 +01:00
parent 2dbc1a8088
commit 5215a1d919
1944 changed files with 203378 additions and 0 deletions
@@ -0,0 +1,2 @@
# secret used to sign session cookies
COOKIE_SECRET="sportsstoresecret"
@@ -0,0 +1,14 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.getEnvironment = exports.Env = void 0;
var Env;
(function (Env) {
Env["Development"] = "development";
Env["Production"] = "production";
})(Env || (exports.Env = Env = {}));
const getEnvironment = () => {
const env = process.env.NODE_ENV;
return env === undefined || env === Env.Development
? Env.Development : Env.Production;
};
exports.getEnvironment = getEnvironment;
@@ -0,0 +1,37 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Env = exports.getEnvironment = exports.getSecret = exports.getConfig = void 0;
const fs_1 = require("fs");
const environment_1 = require("./environment");
Object.defineProperty(exports, "getEnvironment", { enumerable: true, get: function () { return environment_1.getEnvironment; } });
Object.defineProperty(exports, "Env", { enumerable: true, get: function () { return environment_1.Env; } });
const merge_1 = require("./merge");
const dotenv_1 = require("dotenv");
const file = process.env.SERVER_CONFIG ?? "server.config.json";
const data = JSON.parse((0, fs_1.readFileSync)(file).toString());
(0, dotenv_1.config)({
path: (0, environment_1.getEnvironment)().toString() + ".env"
});
try {
const envFile = (0, environment_1.getEnvironment)().toString() + "." + file;
const envData = JSON.parse((0, fs_1.readFileSync)(envFile).toString());
(0, merge_1.merge)(data, envData);
}
catch {
// do nothing - file doesn't exist or isn't readable
}
const getConfig = (path, defaultVal = undefined) => {
const paths = path.split(":");
let val = data;
paths.forEach(p => val = val[p]);
return val ?? defaultVal;
};
exports.getConfig = getConfig;
const getSecret = (name) => {
const secret = process.env[name];
if (secret === undefined) {
throw new Error(`Undefined secret: ${name}`);
}
return secret;
};
exports.getSecret = getSecret;
@@ -0,0 +1,20 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.merge = void 0;
const merge = (target, source) => {
Object.keys(source).forEach(key => {
if (typeof source[key] === "object"
&& !Array.isArray(source[key])) {
if (Object.hasOwn(target, key)) {
(0, exports.merge)(target[key], source[key]);
}
else {
Object.assign(target, source[key]);
}
}
else {
target[key] = source[key];
}
});
};
exports.merge = merge;
@@ -0,0 +1,20 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.merge = void 0;
const merge = (target, source) => {
Object.keys(source).forEach(key => {
if (typeof source[key] === "object"
&& !Array.isArray(source[key])) {
if (Object.hasOwn(target, key)) {
(0, exports.merge)(target[key], source[key]);
}
else {
Object.assign(target, source[key]);
}
}
else {
target[key] = source[key];
}
});
};
exports.merge = merge;
@@ -0,0 +1,17 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.getCartDetail = void 0;
const _1 = require(".");
const getCartDetail = async (cart) => {
const ids = cart.lines.map(l => l.productId);
const db_data = await _1.catalog_repository.getProductDetails(ids);
const products = Object.fromEntries(db_data.map(p => [p.id, p]));
const lines = cart.lines.map(line => ({
product: products[line.productId],
quantity: line.quantity,
subtotal: products[line.productId].price * line.quantity
}));
const total = lines.reduce((total, line) => total + line.subtotal, 0);
return { lines, total };
};
exports.getCartDetail = getCartDetail;
@@ -0,0 +1,19 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.removeLine = exports.addLine = exports.createCart = void 0;
const createCart = () => ({ lines: [] });
exports.createCart = createCart;
const addLine = (cart, productId, quantity) => {
const line = cart.lines.find(l => l.productId == productId);
if (line !== undefined) {
line.quantity += quantity;
}
else {
cart.lines.push({ productId, quantity });
}
};
exports.addLine = addLine;
const removeLine = (cart, productId) => {
cart.lines = cart.lines.filter(l => l.productId !== productId);
};
exports.removeLine = removeLine;
@@ -0,0 +1,2 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,2 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,5 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.catalog_repository = void 0;
const orm_1 = require("./orm");
exports.catalog_repository = new orm_1.CatalogRepoImpl();
@@ -0,0 +1,38 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.BaseRepo = void 0;
const sequelize_1 = require("sequelize");
const config_1 = require("../../config");
const models_1 = require("./models");
const fs_1 = require("fs");
const config = (0, config_1.getConfig)("catalog:orm_repo");
const logging = config.logging
? { logging: console.log, logQueryParameters: true }
: { logging: false };
class BaseRepo {
sequelize;
constructor() {
this.sequelize = new sequelize_1.Sequelize({ ...config.settings, ...logging });
this.initModelsAndDatabase();
}
async initModelsAndDatabase() {
(0, models_1.initializeModels)(this.sequelize);
if (config.reset_db) {
await this.sequelize.drop();
await this.sequelize.sync();
await this.addSeedData();
}
else {
await this.sequelize.sync();
}
}
async addSeedData() {
const data = JSON.parse((0, fs_1.readFileSync)(config.seed_file).toString());
await this.sequelize.transaction(async (transaction) => {
await models_1.SupplierModel.bulkCreate(data.suppliers, { transaction });
await models_1.CategoryModel.bulkCreate(data.categories, { transaction });
await models_1.ProductModel.bulkCreate(data.products, { transaction });
});
}
}
exports.BaseRepo = BaseRepo;
@@ -0,0 +1,9 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.CatalogRepoImpl = void 0;
const core_1 = require("./core");
const queries_1 = require("./queries");
const storage_1 = require("./storage");
const RepoWithQueries = (0, queries_1.AddQueries)(core_1.BaseRepo);
const CompleteRepo = (0, storage_1.AddStorage)(RepoWithQueries);
exports.CatalogRepoImpl = CompleteRepo;
@@ -0,0 +1,29 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.initializeCatalogModels = void 0;
const sequelize_1 = require("sequelize");
const catalog_models_1 = require("./catalog_models");
const primaryKey = {
id: { type: sequelize_1.DataTypes.INTEGER, autoIncrement: true, primaryKey: true }
};
const initializeCatalogModels = (sequelize) => {
catalog_models_1.ProductModel.init({
...primaryKey,
name: { type: sequelize_1.DataTypes.STRING },
description: { type: sequelize_1.DataTypes.STRING },
price: { type: sequelize_1.DataTypes.DECIMAL(10, 2) }
}, { sequelize });
catalog_models_1.CategoryModel.init({
...primaryKey,
name: { type: sequelize_1.DataTypes.STRING }
}, { sequelize });
catalog_models_1.SupplierModel.init({
...primaryKey,
name: { type: sequelize_1.DataTypes.STRING }
}, { sequelize });
catalog_models_1.ProductModel.belongsTo(catalog_models_1.CategoryModel, { foreignKey: "categoryId", as: "category" });
catalog_models_1.ProductModel.belongsTo(catalog_models_1.SupplierModel, { foreignKey: "supplierId", as: "supplier" });
catalog_models_1.CategoryModel.hasMany(catalog_models_1.ProductModel, { foreignKey: "categoryId", as: "products" });
catalog_models_1.SupplierModel.hasMany(catalog_models_1.ProductModel, { foreignKey: "supplierId", as: "products" });
};
exports.initializeCatalogModels = initializeCatalogModels;
@@ -0,0 +1,13 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.SupplierModel = exports.CategoryModel = exports.ProductModel = void 0;
const sequelize_1 = require("sequelize");
class ProductModel extends sequelize_1.Model {
}
exports.ProductModel = ProductModel;
class CategoryModel extends sequelize_1.Model {
}
exports.CategoryModel = CategoryModel;
class SupplierModel extends sequelize_1.Model {
}
exports.SupplierModel = SupplierModel;
@@ -0,0 +1,12 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.initializeModels = exports.SupplierModel = exports.CategoryModel = exports.ProductModel = void 0;
const catalog_helpers_1 = require("./catalog_helpers");
var catalog_models_1 = require("./catalog_models");
Object.defineProperty(exports, "ProductModel", { enumerable: true, get: function () { return catalog_models_1.ProductModel; } });
Object.defineProperty(exports, "CategoryModel", { enumerable: true, get: function () { return catalog_models_1.CategoryModel; } });
Object.defineProperty(exports, "SupplierModel", { enumerable: true, get: function () { return catalog_models_1.SupplierModel; } });
const initializeModels = (sequelize) => {
(0, catalog_helpers_1.initializeCatalogModels)(sequelize);
};
exports.initializeModels = initializeModels;
@@ -0,0 +1,49 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.AddQueries = void 0;
const models_1 = require("./models");
const sequelize_1 = require("sequelize");
function AddQueries(Base) {
return class extends Base {
async getProducts(params) {
const opts = {};
if (params?.page && params.pageSize) {
opts.limit = params?.pageSize,
opts.offset = (params.page - 1) * params.pageSize;
}
if (params?.searchTerm) {
const searchOp = { [sequelize_1.Op.like]: "%" + params.searchTerm + "%" };
opts.where = {
[sequelize_1.Op.or]: { name: searchOp, description: searchOp }
};
}
if (params?.category) {
opts.where = {
...opts.where, categoryId: params.category
};
}
const result = await models_1.ProductModel.findAndCountAll({
include: [
{ model: models_1.SupplierModel, as: "supplier" },
{ model: models_1.CategoryModel, as: "category" }
],
raw: true, nest: true,
...opts
});
const categories = await this.getCategories();
return { products: result.rows, totalCount: result.count, categories };
}
getCategories() {
return models_1.CategoryModel.findAll({ raw: true, nest: true });
}
getSuppliers() {
return models_1.SupplierModel.findAll({ raw: true, nest: true });
}
getProductDetails(ids) {
return models_1.ProductModel.findAll({
where: { id: { [sequelize_1.Op.in]: ids } }, raw: true, nest: true,
});
}
};
}
exports.AddQueries = AddQueries;
@@ -0,0 +1,37 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.AddStorage = void 0;
const models_1 = require("./models");
function AddStorage(Base) {
return class extends Base {
storeProduct(p) {
return this.sequelize.transaction(async (transaction) => {
if (p.category) {
p.category = await this.storeCategory(p.category);
}
if (p.supplier) {
p.supplier = await this.storeSupplier(p.supplier);
}
const [stored] = await models_1.ProductModel.upsert({
id: p.id, name: p.name, description: p.description,
price: p.price, categoryId: p.category?.id,
supplierId: p.supplier?.id
}, { transaction });
return stored;
});
}
async storeCategory(c, transaction) {
const [stored] = await models_1.CategoryModel.upsert({
id: c.id, name: c.name
}, { transaction });
return stored;
}
async storeSupplier(s, transaction) {
const [stored] = await models_1.SupplierModel.upsert({
id: s.id, name: s.name
}, { transaction });
return stored;
}
};
}
exports.AddStorage = AddStorage;
@@ -0,0 +1,28 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.createErrorHandlers = void 0;
const config_1 = require("./config");
require("express-async-errors");
const template400 = (0, config_1.getConfig)("errors:400");
const template500 = (0, config_1.getConfig)("errors:500");
const createErrorHandlers = (app) => {
app.use((req, resp) => {
resp.statusCode = 404;
resp.render(template400);
});
const handler = (error, req, resp, next) => {
console.log(error);
if (resp.headersSent) {
return next(error);
}
try {
resp.statusCode = 500;
resp.render(template500, { error });
}
catch (newErr) {
next(error);
}
};
app.use(handler);
};
exports.createErrorHandlers = createErrorHandlers;
@@ -0,0 +1,5 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.countCartItems = void 0;
const countCartItems = (cart) => cart.lines.reduce((total, line) => total + line.quantity, 0);
exports.countCartItems = countCartItems;
@@ -0,0 +1,70 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.currency = exports.highlight = exports.categoryButtons = exports.pageSizeOptions = exports.pageButtons = exports.escapeUrl = exports.navigationUrl = void 0;
const handlebars_1 = __importDefault(require("handlebars"));
const querystring_1 = require("querystring");
const querystring_2 = require("querystring");
const getData = (options) => {
return { ...options.data.root, ...options.hash };
};
const navigationUrl = (options) => {
const { page, pageSize, category, searchTerm } = getData(options);
return "/?" + (0, querystring_1.stringify)({ page, pageSize, category, searchTerm });
};
exports.navigationUrl = navigationUrl;
const escapeUrl = (url) => (0, querystring_2.escape)(url);
exports.escapeUrl = escapeUrl;
const pageButtons = (options) => {
const { page, pageCount } = getData(options);
let output = "";
for (let i = 1; i <= pageCount; i++) {
output += options.fn({
page, pageCount, index: i, selected: i === page
});
}
return output;
};
exports.pageButtons = pageButtons;
const pageSizeOptions = (options) => {
const { pageSize } = getData(options);
let output = "";
[3, 6, 9].forEach(size => {
output += options.fn({ size,
selected: pageSize === size ? "selected" : "" });
});
return output;
};
exports.pageSizeOptions = pageSizeOptions;
const categoryButtons = (options) => {
const { category, categories } = getData(options);
let output = "";
for (let i = 0; i < categories.length; i++) {
output += options.fn({
id: categories[i].id,
name: categories[i].name,
selected: category === categories[i].id
});
}
return output;
};
exports.categoryButtons = categoryButtons;
const highlight = (value, options) => {
const { searchTerm } = getData(options);
if (searchTerm && searchTerm !== "") {
const regexp = new RegExp(searchTerm, "ig");
const mod = value.replaceAll(regexp, "<strong>$&</strong>");
return new handlebars_1.default.SafeString(mod);
}
return value;
};
exports.highlight = highlight;
const formatter = new Intl.NumberFormat("en-us", {
style: "currency", currency: "USD"
});
const currency = (value) => {
return formatter.format(value);
};
exports.currency = currency;
@@ -0,0 +1,8 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.isDevelopment = void 0;
const config_1 = require("../config");
const isDevelopment = (value) => {
return (0, config_1.getEnvironment)() === config_1.Env.Development;
};
exports.isDevelopment = isDevelopment;
@@ -0,0 +1,42 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.createTemplates = void 0;
const config_1 = require("../config");
const express_handlebars_1 = require("express-handlebars");
const env_helpers = __importStar(require("./env"));
const catalog_helpers = __importStar(require("./catalog_helpers"));
const cart_helpers = __importStar(require("./cart_helpers"));
const location = (0, config_1.getConfig)("templates:location");
const config = (0, config_1.getConfig)("templates:config");
const createTemplates = (app) => {
app.set("views", location);
app.engine("handlebars", (0, express_handlebars_1.engine)({
...config,
helpers: { ...env_helpers, ...catalog_helpers, ...cart_helpers }
}));
app.set("view engine", "handlebars");
};
exports.createTemplates = createTemplates;
@@ -0,0 +1,61 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.createCartRoutes = exports.createCartMiddleware = void 0;
const querystring_1 = require("querystring");
const cart_models_1 = require("../data/cart_models");
const cart_helpers = __importStar(require("../data/cart_helpers"));
const createCartMiddleware = (app) => {
app.use((req, resp, next) => {
resp.locals.cart = req.session.cart = req.session.cart ?? (0, cart_models_1.createCart)();
next();
});
};
exports.createCartMiddleware = createCartMiddleware;
const createCartRoutes = (app) => {
app.post("/cart", (req, resp) => {
const productId = Number.parseInt(req.body.productId);
if (isNaN(productId)) {
throw new Error("ID must be an integer");
}
(0, cart_models_1.addLine)(req.session.cart, productId, 1);
resp.redirect(`/cart?returnUrl=${(0, querystring_1.escape)(req.body.returnUrl ?? "/")}`);
});
app.get("/cart", async (req, resp) => {
const cart = req.session.cart;
resp.render("cart", {
cart: await cart_helpers.getCartDetail(cart),
returnUrl: (0, querystring_1.unescape)(req.query.returnUrl?.toString() ?? "/")
});
});
app.post("/cart/remove", (req, resp) => {
const id = Number.parseInt(req.body.id);
if (!isNaN(id)) {
(0, cart_models_1.removeLine)(req.session.cart, id);
}
resp.redirect(`/cart?returnUrl=${(0, querystring_1.escape)(req.body.returnUrl ?? "/")}`);
});
};
exports.createCartRoutes = createCartRoutes;
@@ -0,0 +1,19 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.createCatalogRoutes = void 0;
const data_1 = require("../data");
const createCatalogRoutes = (app) => {
app.get("/", async (req, resp) => {
const page = Number.parseInt(req.query.page?.toString() ?? "1");
const pageSize = Number.parseInt(req.query.pageSize?.toString() ?? "3");
const searchTerm = req.query.searchTerm?.toString();
const category = Number.parseInt(req.query.category?.toString() ?? "");
const res = await data_1.catalog_repository.getProducts({ page, pageSize,
searchTerm, category });
resp.render("index", { ...res, page, pageSize,
pageCount: Math.ceil(res.totalCount / (pageSize ?? 1)),
searchTerm, category, show_cart: true
});
});
};
exports.createCatalogRoutes = createCatalogRoutes;
@@ -0,0 +1,11 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.createRoutes = void 0;
const catalog_1 = require("./catalog");
const cart_1 = require("./cart");
const createRoutes = (app) => {
(0, cart_1.createCartMiddleware)(app);
(0, catalog_1.createCatalogRoutes)(app);
(0, cart_1.createCartRoutes)(app);
};
exports.createRoutes = createRoutes;
@@ -0,0 +1,26 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const http_1 = require("http");
const express_1 = __importDefault(require("express"));
const helmet_1 = __importDefault(require("helmet"));
const config_1 = require("./config");
const routes_1 = require("./routes");
const helpers_1 = require("./helpers");
const errors_1 = require("./errors");
const sessions_1 = require("./sessions");
const port = (0, config_1.getConfig)("http:port", 5000);
const expressApp = (0, express_1.default)();
expressApp.use((0, helmet_1.default)());
expressApp.use(express_1.default.json());
expressApp.use(express_1.default.urlencoded({ extended: true }));
expressApp.use(express_1.default.static("node_modules/bootstrap/dist"));
expressApp.use(express_1.default.static("node_modules/bootstrap-icons"));
(0, helpers_1.createTemplates)(expressApp);
(0, sessions_1.createSessions)(expressApp);
(0, routes_1.createRoutes)(expressApp);
(0, errors_1.createErrorHandlers)(expressApp);
const server = (0, http_1.createServer)(expressApp);
server.listen(port, () => console.log(`HTTP Server listening on port ${port}`));
@@ -0,0 +1,36 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.createSessions = void 0;
const sequelize_1 = require("sequelize");
const config_1 = require("./config");
const express_session_1 = __importDefault(require("express-session"));
const connect_session_sequelize_1 = __importDefault(require("connect-session-sequelize"));
const config = (0, config_1.getConfig)("sessions");
const secret = (0, config_1.getSecret)("COOKIE_SECRET");
const logging = config.orm.logging
? { logging: console.log, logQueryParameters: true }
: { logging: false };
const createSessions = (app) => {
const sequelize = new sequelize_1.Sequelize({
...config.orm.settings, ...logging
});
const store = new ((0, connect_session_sequelize_1.default)(express_session_1.default.Store))({
db: sequelize
});
if (config.reset_db === true) {
sequelize.drop().then(() => store.sync());
}
else {
store.sync();
}
app.use((0, express_session_1.default)({
secret, store,
resave: true, saveUninitialized: false,
cookie: { maxAge: config.maxAgeHrs * 60 * 60 * 1000,
sameSite: "strict" }
}));
};
exports.createSessions = createSessions;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,44 @@
{
"name": "sportsstore",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"watch": "tsc-watch --noClear --onsuccess \"node dist/server.js\"",
"start": "nodemon --exec npm run watch"
},
"nodemonConfig": {
"ext": "js,handlebars,json",
"ignore": [
"dist/**",
"node_modules/**"
]
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@tsconfig/node20": "^20.1.2",
"@types/cookie-parser": "^1.4.6",
"@types/express": "^4.17.20",
"@types/express-session": "^1.17.10",
"@types/node": "^20.6.1",
"nodemon": "^3.0.3",
"tsc-watch": "^6.0.4",
"typescript": "^5.2.2"
},
"dependencies": {
"bootstrap": "^5.3.2",
"bootstrap-icons": "^1.11.3",
"connect-session-sequelize": "^7.1.7",
"dotenv": "^16.4.4",
"express": "^4.18.2",
"express-async-errors": "^3.1.1",
"express-handlebars": "^7.1.2",
"express-session": "^1.17.3",
"handlebars": "^4.7.8",
"helmet": "^7.1.0",
"sequelize": "^6.35.1",
"sqlite3": "^5.1.6"
}
}
@@ -0,0 +1,40 @@
{
"suppliers": [
{ "id": 1, "name": "Acme Industries"},
{ "id": 2, "name": "Big Boat Co"},
{ "id": 3, "name": "London Chess"}
],
"categories": [
{ "id": 1, "name": "Watersports"},
{ "id": 2, "name": "Soccer"},
{ "id": 3, "name": "Chess"}
],
"products": [
{"id": 1, "name": "Kayak", "description": "A boat for one person",
"price": 275.00, "categoryId": 1, "supplierId": 2 },
{"id": 2, "name": "Lifejacket",
"description": "Protective and fashionable",
"price": 48.95, "categoryId": 1, "supplierId": 2 },
{ "id": 3, "name": "Soccer Ball",
"description": "FIFA-approved size and weight",
"price": 19.50, "categoryId": 2, "supplierId": 1 },
{ "id": 4, "name": "Corner Flags",
"description": "Give your playing field a professional touch",
"price": 34.95, "categoryId": 2, "supplierId": 1 },
{ "id": 5, "name": "Stadium",
"description": "Flat-packed 35,000-seat stadium",
"price": 79500, "categoryId": 2, "supplierId": 1 },
{ "id": 6, "name": "Thinking Cap",
"description": "Improve brain efficiency by 75%", "price": 16,
"categoryId": 3, "supplierId": 3 },
{ "id": 7, "name": "Unsteady Chair",
"description": "Secretly give your opponent a disadvantage",
"price": 29.95, "categoryId": 3, "supplierId": 3 },
{ "id": 8, "name": "Human Chess Board",
"description": "A fun game for the family", "price": 75,
"categoryId": 3, "supplierId": 3 },
{ "id": 9, "name": "Bling King",
"description": "Gold-plated, diamond-studded King",
"price": 1200, "categoryId": 3, "supplierId": 3 }
]
}
@@ -0,0 +1,39 @@
{
"http": {
"port": 5000
},
"templates": {
"location": "templates",
"config": {
"layoutsDir": "templates",
"defaultLayout": "main_layout.handlebars",
"partialsDir": "templates"
}
},
"errors": {
"400": "not_found",
"500": "error"
},
"catalog": {
"orm_repo": {
"settings": {
"dialect": "sqlite",
"storage": "catalog.db"
},
"logging": true,
"reset_db": true,
"seed_file": "products.json"
}
},
"sessions": {
"maxAgeHrs": 2,
"reset_db": true,
"orm": {
"settings": {
"dialect": "sqlite",
"storage": "sessions.db"
},
"logging": true
}
}
}
@@ -0,0 +1,9 @@
export enum Env {
Development = "development", Production = "production"
}
export const getEnvironment = () : Env => {
const env = process.env.NODE_ENV;
return env === undefined || env === Env.Development
? Env.Development : Env.Production;
}
@@ -0,0 +1,36 @@
import { readFileSync } from "fs";
import { getEnvironment, Env } from "./environment";
import { merge } from "./merge";
import { config as dotenvconfig } from "dotenv";
const file = process.env.SERVER_CONFIG ?? "server.config.json"
const data = JSON.parse(readFileSync(file).toString());
dotenvconfig({
path: getEnvironment().toString() + ".env"
})
try {
const envFile = getEnvironment().toString() + "." + file;
const envData = JSON.parse(readFileSync(envFile).toString());
merge(data, envData);
} catch {
// do nothing - file doesn't exist or isn't readable
}
export const getConfig = (path: string, defaultVal: any = undefined) => {
const paths = path.split(":");
let val = data;
paths.forEach(p => val = val[p]);
return val ?? defaultVal;
}
export const getSecret = (name: string) => {
const secret = process.env[name];
if (secret === undefined) {
throw new Error(`Undefined secret: ${name}`);
}
return secret;
}
export { getEnvironment, Env };
@@ -0,0 +1,14 @@
export const merge = (target: any, source: any) : any => {
Object.keys(source).forEach(key => {
if (typeof source[key] === "object"
&& !Array.isArray(source[key])) {
if (Object.hasOwn(target, key)) {
merge(target[key], source[key]);
} else {
Object.assign(target, source[key])
}
} else {
target[key] = source[key];
}
});
}
@@ -0,0 +1,30 @@
import { catalog_repository } from ".";
import { Cart } from "./cart_models";
import { Product } from "./catalog_models"
export interface CartDetail {
lines: {
product: Product,
quantity: number,
subtotal: number
}[],
total: number;
}
export const getCartDetail = async (cart: Cart) : Promise<CartDetail> => {
const ids = cart.lines.map(l => l.productId);
const db_data = await catalog_repository.getProductDetails(ids);
const products = Object.fromEntries(db_data.map(p => [p.id, p]));
const lines = cart.lines.map(line => ({
product: products[line.productId],
quantity: line.quantity,
subtotal: products[line.productId].price * line.quantity
}));
const total = lines.reduce((total, line) => total + line.subtotal, 0);
return { lines, total }
}
@@ -0,0 +1,23 @@
export interface CartLine {
productId: number;
quantity: number;
}
export interface Cart {
lines: CartLine[];
}
export const createCart = () : Cart => ({ lines: [] });
export const addLine = (cart: Cart, productId: number, quantity: number) => {
const line = cart.lines.find(l => l.productId == productId);
if (line !== undefined) {
line.quantity += quantity;
} else {
cart.lines.push({ productId, quantity })
}
}
export const removeLine = (cart: Cart, productId: number) => {
cart.lines = cart.lines.filter(l => l.productId !== productId);
}
@@ -0,0 +1,36 @@
export interface Product {
id?: number;
name: string;
description: string;
price: number;
category?: Category;
supplier?: Supplier;
}
export interface Category {
id?: number;
name: string;
products?: Product[];
}
export interface Supplier {
id?: number;
name: string;
products?: Product[];
}
export interface ProductQueryParameters {
pageSize?: number;
page?: number;
category?: number;
searchTerm?: string;
}
export interface ProductQueryResult {
products: Product[];
totalCount: number;
categories: Category[];
}
@@ -0,0 +1,19 @@
import { Category, Product, Supplier, ProductQueryParameters,
ProductQueryResult } from "./catalog_models";
export interface CatalogRepository {
getProducts(params?: ProductQueryParameters): Promise<ProductQueryResult>;
getProductDetails(ids: number[]): Promise<Product[]>;
storeProduct(p: Product): Promise<Product>;
getCategories() : Promise<Category[]>;
storeCategory(c: Category): Promise<Category>;
getSuppliers(): Promise<Supplier[]>;
storeSupplier(s: Supplier): Promise<Supplier>;
}
@@ -0,0 +1,4 @@
import { CatalogRepository } from "./catalog_repository";
import { CatalogRepoImpl} from "./orm";
export const catalog_repository: CatalogRepository = new CatalogRepoImpl();
@@ -0,0 +1,41 @@
import { Sequelize } from "sequelize";
import { getConfig } from "../../config";
import { initializeModels, CategoryModel, ProductModel, SupplierModel }
from "./models";
import { readFileSync } from "fs";
const config = getConfig("catalog:orm_repo");
const logging = config.logging
? { logging: console.log, logQueryParameters: true}
: { logging: false };
export class BaseRepo {
sequelize: Sequelize;
constructor() {
this.sequelize = new Sequelize({ ...config.settings, ...logging })
this.initModelsAndDatabase();
}
async initModelsAndDatabase() : Promise<void> {
initializeModels(this.sequelize);
if (config.reset_db) {
await this.sequelize.drop();
await this.sequelize.sync();
await this.addSeedData();
} else {
await this.sequelize.sync();
}
}
async addSeedData() {
const data = JSON.parse(readFileSync(config.seed_file).toString());
await this.sequelize.transaction(async (transaction) => {
await SupplierModel.bulkCreate(data.suppliers, { transaction });
await CategoryModel.bulkCreate(data.categories, { transaction });
await ProductModel.bulkCreate(data.products, { transaction });
});
}
}
export type Constructor<T = {}> = new (...args: any[]) => T;
@@ -0,0 +1,9 @@
import { CatalogRepository } from "../catalog_repository";
import { BaseRepo } from "./core";
import { AddQueries } from "./queries";
import { AddStorage } from "./storage";
const RepoWithQueries = AddQueries(BaseRepo);
const CompleteRepo = AddStorage(RepoWithQueries);
export const CatalogRepoImpl = CompleteRepo;
@@ -0,0 +1,35 @@
import { DataTypes, Sequelize } from "sequelize";
import { CategoryModel, ProductModel, SupplierModel } from "./catalog_models";
const primaryKey = {
id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true }
};
export const initializeCatalogModels = (sequelize: Sequelize) => {
ProductModel.init({
...primaryKey,
name: { type: DataTypes.STRING},
description: { type: DataTypes.STRING},
price: { type: DataTypes.DECIMAL(10, 2) }
}, { sequelize })
CategoryModel.init({
...primaryKey,
name: { type: DataTypes.STRING}
}, { sequelize });
SupplierModel.init({
...primaryKey,
name: { type: DataTypes.STRING}
}, { sequelize})
ProductModel.belongsTo(CategoryModel,
{ foreignKey: "categoryId", as: "category"});
ProductModel.belongsTo(SupplierModel,
{ foreignKey: "supplierId", as: "supplier"});
CategoryModel.hasMany(ProductModel,
{ foreignKey: "categoryId", as: "products"});
SupplierModel.hasMany(ProductModel,
{ foreignKey: "supplierId", as: "products"});
}
@@ -0,0 +1,36 @@
import { Model, CreationOptional, ForeignKey, InferAttributes,
InferCreationAttributes } from "sequelize";
export class ProductModel extends Model<InferAttributes<ProductModel>,
InferCreationAttributes<ProductModel>> {
declare id?: CreationOptional<number>;
declare name: string;
declare description: string;
declare price: number;
declare categoryId: ForeignKey<CategoryModel["id"]>;
declare supplierId: ForeignKey<SupplierModel["id"]>;
declare category?: InferAttributes<CategoryModel>
declare supplier?: InferAttributes<SupplierModel>
}
export class CategoryModel extends Model<InferAttributes<CategoryModel>,
InferCreationAttributes<CategoryModel>> {
declare id?: CreationOptional<number>;
declare name: string;
declare products?: InferAttributes<ProductModel>[];
}
export class SupplierModel extends Model<InferAttributes<SupplierModel>,
InferCreationAttributes<SupplierModel>> {
declare id?: CreationOptional<number>;
declare name: string;
declare products?: InferAttributes<ProductModel>[];
}
@@ -0,0 +1,8 @@
import { Sequelize } from "sequelize";
import { initializeCatalogModels } from "./catalog_helpers";
export { ProductModel, CategoryModel, SupplierModel } from "./catalog_models";
export const initializeModels = (sequelize: Sequelize) => {
initializeCatalogModels(sequelize);
}
@@ -0,0 +1,51 @@
import { CategoryModel, ProductModel, SupplierModel } from "./models";
import { BaseRepo, Constructor } from "./core"
import { ProductQueryParameters } from "../catalog_models";
import { Op } from "sequelize";
export function AddQueries<TBase extends Constructor<BaseRepo>>(Base: TBase) {
return class extends Base {
async getProducts(params?: ProductQueryParameters) {
const opts: any = {};
if (params?.page && params.pageSize) {
opts.limit = params?.pageSize,
opts.offset = (params.page -1) * params.pageSize
}
if(params?.searchTerm) {
const searchOp = { [Op.like]: "%" + params.searchTerm + "%"};
opts.where = {
[Op.or]: { name: searchOp, description: searchOp }
}
}
if (params?.category) {
opts.where = {
...opts.where, categoryId: params.category
}
}
const result = await ProductModel.findAndCountAll({
include: [
{model: SupplierModel, as: "supplier" },
{model: CategoryModel, as: "category"}],
raw: true, nest: true,
...opts
});
const categories = await this.getCategories();
return { products: result.rows, totalCount: result.count, categories };
}
getCategories() {
return CategoryModel.findAll({raw: true, nest: true})
}
getSuppliers() {
return SupplierModel.findAll({raw: true, nest: true});
}
getProductDetails(ids: number[]) {
return ProductModel.findAll({
where: { id: { [Op.in]: ids }}, raw: true, nest: true,
});
}
}
}
@@ -0,0 +1,42 @@
import { Transaction } from "sequelize";
import { Category, Product, Supplier } from "../catalog_models";
import { CategoryModel, ProductModel, SupplierModel } from "./models";
import { BaseRepo, Constructor } from "./core"
export function AddStorage<TBase extends Constructor<BaseRepo>>(Base: TBase) {
return class extends Base {
storeProduct(p: Product) {
return this.sequelize.transaction(async (transaction) => {
if (p.category) {
p.category = await this.storeCategory(p.category)
}
if (p.supplier) {
p.supplier = await this.storeSupplier(p.supplier);
}
const [stored] = await ProductModel.upsert({
id: p.id, name: p.name, description: p.description,
price: p.price, categoryId: p.category?.id,
supplierId: p.supplier?.id
}, { transaction });
return stored;
});
}
async storeCategory(c: Category, transaction?: Transaction) {
const [stored] = await CategoryModel.upsert({
id: c.id, name: c.name
}, { transaction});
return stored;
}
async storeSupplier(s: Supplier, transaction?: Transaction) {
const [stored] = await SupplierModel.upsert({
id: s.id, name: s.name
}, {transaction});
return stored;
}
}
}
@@ -0,0 +1,28 @@
import { Express, ErrorRequestHandler } from "express";
import { getConfig } from "./config";
import "express-async-errors";
const template400 = getConfig("errors:400");
const template500 = getConfig("errors:500");
export const createErrorHandlers = (app: Express) => {
app.use((req, resp) => {
resp.statusCode = 404;
resp.render(template400);
});
const handler: ErrorRequestHandler = (error, req, resp, next) => {
console.log(error);
if (resp.headersSent) {
return next(error);
}
try {
resp.statusCode = 500;
resp.render(template500, { error} );
} catch (newErr) {
next(error);
}
}
app.use(handler);
}
@@ -0,0 +1,4 @@
import { Cart } from "../data/cart_models";
export const countCartItems = (cart: Cart) : number =>
cart.lines.reduce((total, line) => total + line.quantity, 0);
@@ -0,0 +1,68 @@
import Handlebars, { HelperOptions } from "handlebars";
import { stringify } from "querystring";
import { escape } from "querystring";
const getData = (options:HelperOptions) => {
return {...options.data.root, ...options.hash}
};
export const navigationUrl = (options: HelperOptions) => {
const { page, pageSize, category, searchTerm } = getData(options);
return "/?" + stringify({ page, pageSize, category, searchTerm });
}
export const escapeUrl = (url: string) => escape(url);
export const pageButtons = (options: HelperOptions) => {
const { page, pageCount } = getData(options);
let output = "";
for (let i = 1; i <= pageCount; i++) {
output += options.fn({
page, pageCount, index: i, selected: i === page
});
}
return output;
}
export const pageSizeOptions = (options: HelperOptions) => {
const { pageSize } = getData(options);
let output = "";
[3, 6, 9].forEach(size => {
output += options.fn({ size,
selected: pageSize === size ? "selected": ""})
})
return output;
}
export const categoryButtons = (options: HelperOptions) => {
const { category, categories } = getData(options);
let output = "";
for (let i = 0; i < categories.length; i++) {
output += options.fn({
id: categories[i].id,
name: categories[i].name,
selected: category === categories[i].id
})
}
return output;
}
export const highlight = (value: string, options: HelperOptions) => {
const { searchTerm } = getData(options);
if (searchTerm && searchTerm !== "") {
const regexp = new RegExp(searchTerm, "ig");
const mod = value.replaceAll(regexp, "<strong>$&</strong>");
return new Handlebars.SafeString(mod);
}
return value;
}
const formatter = new Intl.NumberFormat("en-us", {
style: "currency", currency: "USD"
})
export const currency = (value: number) => {
return formatter.format(value);
}
@@ -0,0 +1,5 @@
import { Env, getEnvironment } from "../config";
export const isDevelopment = (value: any) => {
return getEnvironment() === Env.Development
}
@@ -0,0 +1,19 @@
import { Express } from "express";
import { getConfig } from "../config";
import { engine } from "express-handlebars";
import * as env_helpers from "./env";
import * as catalog_helpers from "./catalog_helpers";
import * as cart_helpers from "./cart_helpers";
const location = getConfig("templates:location");
const config = getConfig("templates:config");
export const createTemplates = (app: Express) => {
app.set("views", location);
app.engine("handlebars", engine({
...config,
helpers: {...env_helpers, ...catalog_helpers, ...cart_helpers}
}));
app.set("view engine", "handlebars");
}
@@ -0,0 +1,45 @@
import { Express } from "express";
import { escape, unescape } from "querystring";
import { Cart, addLine, createCart, removeLine } from "../data/cart_models";
import * as cart_helpers from "../data/cart_helpers";
declare module "express-session" {
interface SessionData {
cart?: Cart;
}
}
export const createCartMiddleware = (app: Express) => {
app.use((req, resp, next) => {
resp.locals.cart = req.session.cart = req.session.cart ?? createCart()
next();
})
}
export const createCartRoutes = (app: Express) => {
app.post("/cart", (req, resp) => {
const productId = Number.parseInt(req.body.productId);
if (isNaN(productId)) {
throw new Error("ID must be an integer");
}
addLine(req.session.cart as Cart, productId, 1);
resp.redirect(`/cart?returnUrl=${escape(req.body.returnUrl ?? "/")}`);
});
app.get("/cart", async (req, resp) => {
const cart = req.session.cart as Cart;
resp.render("cart", {
cart: await cart_helpers.getCartDetail(cart),
returnUrl: unescape(req.query.returnUrl?.toString() ?? "/")
});
});
app.post("/cart/remove", (req, resp) => {
const id = Number.parseInt(req.body.id);
if (!isNaN(id)) {
removeLine(req.session.cart as Cart, id);
}
resp.redirect(`/cart?returnUrl=${escape(req.body.returnUrl ?? "/")}`);
});
}
@@ -0,0 +1,20 @@
import { Express } from "express";
import { catalog_repository } from "../data";
export const createCatalogRoutes = (app: Express) => {
app.get("/", async (req, resp) => {
const page = Number.parseInt(req.query.page?.toString() ?? "1");
const pageSize =Number.parseInt(req.query.pageSize?.toString() ?? "3")
const searchTerm = req.query.searchTerm?.toString();
const category = Number.parseInt(req.query.category?.toString() ?? "")
const res = await catalog_repository.getProducts({ page, pageSize,
searchTerm, category});
resp.render("index", { ...res, page, pageSize,
pageCount: Math.ceil(res.totalCount / (pageSize ?? 1)),
searchTerm, category, show_cart: true
});
});
}
@@ -0,0 +1,11 @@
import { Express } from "express";
import { createCatalogRoutes } from "./catalog";
import { createCartMiddleware, createCartRoutes } from "./cart";
export const createRoutes = (app: Express) => {
createCartMiddleware(app);
createCatalogRoutes(app);
createCartRoutes(app);
}
@@ -0,0 +1,30 @@
import { createServer } from "http";
import express, { Express } from "express";
import helmet from "helmet";
import { getConfig } from "./config";
import { createRoutes } from "./routes";
import { createTemplates } from "./helpers";
import { createErrorHandlers } from "./errors";
import { createSessions } from "./sessions";
const port = getConfig("http:port", 5000);
const expressApp: Express = express();
expressApp.use(helmet());
expressApp.use(express.json());
expressApp.use(express.urlencoded({extended: true}))
expressApp.use(express.static("node_modules/bootstrap/dist"));
expressApp.use(express.static("node_modules/bootstrap-icons"));
createTemplates(expressApp);
createSessions(expressApp);
createRoutes(expressApp);
createErrorHandlers(expressApp);
const server = createServer(expressApp);
server.listen(port,
() => console.log(`HTTP Server listening on port ${port}`));
@@ -0,0 +1,37 @@
import { Express } from "express";
import { Sequelize } from "sequelize";
import { getConfig, getSecret } from "./config";
import session from "express-session";
import sessionStore from "connect-session-sequelize";
const config = getConfig("sessions");
const secret = getSecret("COOKIE_SECRET");
const logging = config.orm.logging
? { logging: console.log, logQueryParameters: true}
: { logging: false };
export const createSessions = (app: Express) => {
const sequelize = new Sequelize({
...config.orm.settings, ...logging
});
const store = new (sessionStore(session.Store))({
db: sequelize
});
if (config.reset_db === true) {
sequelize.drop().then(() => store.sync());
} else {
store.sync();
}
app.use(session({
secret, store,
resave: true, saveUninitialized: false,
cookie: { maxAge: config.maxAgeHrs * 60 * 60 * 1000,
sameSite: "strict" }
}));
}
@@ -0,0 +1,32 @@
<h2>Your cart</h2>
<table class="table table-bordered table-striped">
<thead>
<tr>
<th class="text-end">Quantity</th><th>Item</th>
<th class="text-end">Price</th><th class="text-end">Subtotal</th>
<th></th>
</tr>
</thead>
<tbody>
{{#unless cart.lines}}
<tr><td colspan="5" class="text-center">Cart is empty</td></tr>
{{/unless}}
{{#each cart.lines}}
{{> cart_line returnUrl=../returnUrl }}
{{/each }}
</tbody>
<tfoot>
<tr>
<td colspan="3" class="text-end">Total:</td>
<td class="text-end">{{ currency cart.total }}</td>
</tr>
</tfoot>
</table>
<div class="text-center">
<a class="btn btn-primary" href="{{ returnUrl }}">Continue Shopping</a>
{{#if cart.lines}}
<a class="btn btn-primary" href="/checkout">Checkout</a>
{{else}}
<button class="btn btn-primary" disabled>Checkout</button>
{{/if}}
</div>
@@ -0,0 +1,15 @@
<tr>
<td class="text-end">{{ quantity }} </td>
<td class="text-left">{{ product.name }}</td>
<td class="text-end">{{ currency product.price }}</td>
<td class="text-end">{{ currency subtotal }}</td>
<td class="text-center">
<form method="post" action="/cart/remove">
<input type="hidden" name="id" value="{{ product.id }}">
<input type="hidden" name="returnUrl" value="{{ returnUrl }}">
<button type="submit" class="btn btn-sm btn-danger">
Remove
</button>
</form>
</td>
</tr>
@@ -0,0 +1,10 @@
{{#if cart.lines}}
<small class="navbar-text">{{ countCartItems cart }} item(s)</small>
{{else}}
<small class="navbar-text">(Empty)</small>
{{/if}}
<a class="btn btn-sm btn-secondary navbar-btn"
href="/cart?returnUrl={{ escapeUrl ( navigationUrl ) }}">
<i class="bi-cart"></i>
</a>
@@ -0,0 +1,16 @@
<div class="d-grid gap-2 py-2">
<a class="btn btn-outline-secondary"
href="{{navigationUrl category="" page=1 searchTerm="" }}">
Home
</a>
{{#categoryButtons }}
{{#if selected }}
<a class="btn btn-secondary">{{ name }}</a>
{{else }}
<a class="btn btn-outline-secondary"
href="{{navigationUrl category=id page=1}}">
{{ name }}
</a>
{{/if }}
{{/categoryButtons }}
</div>
@@ -0,0 +1,12 @@
<div class="h2 bg-danger text-white text-center p-2 my-2">
500 - Error
</div>
<div class="text-center">
<a class="btn btn-secondary" href="/">OK</a>
</div>
{{#if (isDevelopment) }}
<div class="h4 bg-danger text-white p-1 mt-2">Error Details</div>
<div class="h5 p-1">Message: {{ error.message }}</div>
<div class="font-monospace p-1">{{error.stack}}</div>
{{/if }}
@@ -0,0 +1,15 @@
<div class="container-fluid">
<div class="row">
<div class="col-2">
{{> category_controls }}
</div>
<div class="col">
{{> search_controls }}
{{#unless products}}<h4>No products</h4>{{/unless}}
{{#each products }}
{{> product this }}
{{/each}}
{{> page_controls }}
</div>
</div>
</div>
@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html>
<head>
<link href="/css/bootstrap.min.css" rel="stylesheet" />
<link href="/font/bootstrap-icons.min.css" rel="stylesheet">
</head>
<body>
<div class="bg-dark text-white py-2 px-1">
<div class="container-fluid">
<div class="row">
<div class="col align-baseline pt-1">SPORTS STORE</div>
<div class="col-auto text-end">
{{#if show_cart}}
{{> cart_summary }}
{{/if}}
</div>
</div>
</div>
</div>
{{{ body }}}
</body>
</html>
@@ -0,0 +1,6 @@
<div class="h2 bg-danger text-white text-center p-2 my-2">
404 - Not Found
</div>
<div class="text-center">
<a class="btn btn-secondary" href="/">OK</a>
</div>
@@ -0,0 +1,31 @@
<div class="container-fluid">
<div class="row">
<div class="col">
{{#pageButtons }}
{{#if selected}}
<button class="btn btn-sm btn-light active mr-1 p-2">
{{index}}
</button>
{{else}}
<a class="btn btn-sm btn-light mr-1 p-2"
href="{{navigationUrl page=index }}">{{index}}</a>
{{/if}}
{{/pageButtons}}
</div>
<div class="col-auto text-end">
<form id="pageSizeForm" method="get">
<select class="form-select" name="pageSize">
{{#pageSizeOptions }}
<option value="{{size}}" {{selected}}>
{{size}} per page
</option>
{{/pageSizeOptions }}
</select>
</form>
</div>
<div class="col-auto">
<button class="btn btn-light mr-2" type="submit"
form="pageSizeForm">Go</button>
</div>
</div>
</div>
@@ -0,0 +1,21 @@
<div class="card card-outline-primary m-1 p-1">
<div class="bg-faded p-1">
<h4>
{{ highlight name }}
<span class="badge rounded-pill bg-primary text-white"
style="float:right">
<small>{{ currency price }}</small>
</span>
</h4>
</div>
<div class="card-text p-1">
<span class="float-start">{{ highlight description }}</span>
<form method="post" action="/cart">
<input type="hidden" name="returnUrl" value="{{navigationUrl}}">
<input type="hidden" name="productId" value="{{id}}">
<button type="submit" class="btn btn-sm btn-success float-end">
Add To Cart
</button>
</form>
</div>
</div>
@@ -0,0 +1,13 @@
<form class="row row-cols my-2" method="get">
<div class="col">
<input type="hidden" name="pageSize" value="{{pageSize}}">
<input type="hidden" name="category" value="{{category}}">
<input class="form-control" name="searchTerm"
placeholder="Product Search" value="{{searchTerm}}">
</div>
<div class="col-auto">
<button class="btn btn-small btn-secondary" type="submit">
Search
</button>
</div>
</form>
@@ -0,0 +1,8 @@
{
"extends": "@tsconfig/node20/tsconfig.json",
"compilerOptions": {
"rootDir": "src/",
"outDir": "dist/"
},
"include": ["src/**/*"]
}
Binary file not shown.
@@ -0,0 +1,2 @@
# secret used to sign session cookies
COOKIE_SECRET="sportsstoresecret"
@@ -0,0 +1,14 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.getEnvironment = exports.Env = void 0;
var Env;
(function (Env) {
Env["Development"] = "development";
Env["Production"] = "production";
})(Env || (exports.Env = Env = {}));
const getEnvironment = () => {
const env = process.env.NODE_ENV;
return env === undefined || env === Env.Development
? Env.Development : Env.Production;
};
exports.getEnvironment = getEnvironment;
@@ -0,0 +1,37 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Env = exports.getEnvironment = exports.getSecret = exports.getConfig = void 0;
const fs_1 = require("fs");
const environment_1 = require("./environment");
Object.defineProperty(exports, "getEnvironment", { enumerable: true, get: function () { return environment_1.getEnvironment; } });
Object.defineProperty(exports, "Env", { enumerable: true, get: function () { return environment_1.Env; } });
const merge_1 = require("./merge");
const dotenv_1 = require("dotenv");
const file = process.env.SERVER_CONFIG ?? "server.config.json";
const data = JSON.parse((0, fs_1.readFileSync)(file).toString());
(0, dotenv_1.config)({
path: (0, environment_1.getEnvironment)().toString() + ".env"
});
try {
const envFile = (0, environment_1.getEnvironment)().toString() + "." + file;
const envData = JSON.parse((0, fs_1.readFileSync)(envFile).toString());
(0, merge_1.merge)(data, envData);
}
catch {
// do nothing - file doesn't exist or isn't readable
}
const getConfig = (path, defaultVal = undefined) => {
const paths = path.split(":");
let val = data;
paths.forEach(p => val = val[p]);
return val ?? defaultVal;
};
exports.getConfig = getConfig;
const getSecret = (name) => {
const secret = process.env[name];
if (secret === undefined) {
throw new Error(`Undefined secret: ${name}`);
}
return secret;
};
exports.getSecret = getSecret;
@@ -0,0 +1,20 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.merge = void 0;
const merge = (target, source) => {
Object.keys(source).forEach(key => {
if (typeof source[key] === "object"
&& !Array.isArray(source[key])) {
if (Object.hasOwn(target, key)) {
(0, exports.merge)(target[key], source[key]);
}
else {
Object.assign(target, source[key]);
}
}
else {
target[key] = source[key];
}
});
};
exports.merge = merge;
@@ -0,0 +1,20 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.merge = void 0;
const merge = (target, source) => {
Object.keys(source).forEach(key => {
if (typeof source[key] === "object"
&& !Array.isArray(source[key])) {
if (Object.hasOwn(target, key)) {
(0, exports.merge)(target[key], source[key]);
}
else {
Object.assign(target, source[key]);
}
}
else {
target[key] = source[key];
}
});
};
exports.merge = merge;
@@ -0,0 +1,17 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.getCartDetail = void 0;
const _1 = require(".");
const getCartDetail = async (cart) => {
const ids = cart.lines.map(l => l.productId);
const db_data = await _1.catalog_repository.getProductDetails(ids);
const products = Object.fromEntries(db_data.map(p => [p.id, p]));
const lines = cart.lines.map(line => ({
product: products[line.productId],
quantity: line.quantity,
subtotal: products[line.productId].price * line.quantity
}));
const total = lines.reduce((total, line) => total + line.subtotal, 0);
return { lines, total };
};
exports.getCartDetail = getCartDetail;
@@ -0,0 +1,19 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.removeLine = exports.addLine = exports.createCart = void 0;
const createCart = () => ({ lines: [] });
exports.createCart = createCart;
const addLine = (cart, productId, quantity) => {
const line = cart.lines.find(l => l.productId == productId);
if (line !== undefined) {
line.quantity += quantity;
}
else {
cart.lines.push({ productId, quantity });
}
};
exports.addLine = addLine;
const removeLine = (cart, productId) => {
cart.lines = cart.lines.filter(l => l.productId !== productId);
};
exports.removeLine = removeLine;
@@ -0,0 +1,2 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,2 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,2 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,7 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.order_repository = exports.catalog_repository = void 0;
const orm_1 = require("./orm");
const repo = new orm_1.CatalogRepoImpl();
exports.catalog_repository = repo;
exports.order_repository = repo;
@@ -0,0 +1,2 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,2 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,38 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.BaseRepo = void 0;
const sequelize_1 = require("sequelize");
const config_1 = require("../../config");
const models_1 = require("./models");
const fs_1 = require("fs");
const config = (0, config_1.getConfig)("catalog:orm_repo");
const logging = config.logging
? { logging: console.log, logQueryParameters: true }
: { logging: false };
class BaseRepo {
sequelize;
constructor() {
this.sequelize = new sequelize_1.Sequelize({ ...config.settings, ...logging });
this.initModelsAndDatabase();
}
async initModelsAndDatabase() {
(0, models_1.initializeModels)(this.sequelize);
if (config.reset_db) {
await this.sequelize.drop();
await this.sequelize.sync();
await this.addSeedData();
}
else {
await this.sequelize.sync();
}
}
async addSeedData() {
const data = JSON.parse((0, fs_1.readFileSync)(config.seed_file).toString());
await this.sequelize.transaction(async (transaction) => {
await models_1.SupplierModel.bulkCreate(data.suppliers, { transaction });
await models_1.CategoryModel.bulkCreate(data.categories, { transaction });
await models_1.ProductModel.bulkCreate(data.products, { transaction });
});
}
}
exports.BaseRepo = BaseRepo;
@@ -0,0 +1,11 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.CatalogRepoImpl = void 0;
const core_1 = require("./core");
const queries_1 = require("./queries");
const storage_1 = require("./storage");
const order_queries_1 = require("./order_queries");
const order_storage_1 = require("./order_storage");
const CatalogRepo = (0, storage_1.AddStorage)((0, queries_1.AddQueries)(core_1.BaseRepo));
const RepoWithOrders = (0, order_storage_1.AddOrderStorage)((0, order_queries_1.AddOrderQueries)(CatalogRepo));
exports.CatalogRepoImpl = RepoWithOrders;
@@ -0,0 +1,29 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.initializeCatalogModels = void 0;
const sequelize_1 = require("sequelize");
const catalog_models_1 = require("./catalog_models");
const primaryKey = {
id: { type: sequelize_1.DataTypes.INTEGER, autoIncrement: true, primaryKey: true }
};
const initializeCatalogModels = (sequelize) => {
catalog_models_1.ProductModel.init({
...primaryKey,
name: { type: sequelize_1.DataTypes.STRING },
description: { type: sequelize_1.DataTypes.STRING },
price: { type: sequelize_1.DataTypes.DECIMAL(10, 2) }
}, { sequelize });
catalog_models_1.CategoryModel.init({
...primaryKey,
name: { type: sequelize_1.DataTypes.STRING }
}, { sequelize });
catalog_models_1.SupplierModel.init({
...primaryKey,
name: { type: sequelize_1.DataTypes.STRING }
}, { sequelize });
catalog_models_1.ProductModel.belongsTo(catalog_models_1.CategoryModel, { foreignKey: "categoryId", as: "category" });
catalog_models_1.ProductModel.belongsTo(catalog_models_1.SupplierModel, { foreignKey: "supplierId", as: "supplier" });
catalog_models_1.CategoryModel.hasMany(catalog_models_1.ProductModel, { foreignKey: "categoryId", as: "products" });
catalog_models_1.SupplierModel.hasMany(catalog_models_1.ProductModel, { foreignKey: "supplierId", as: "products" });
};
exports.initializeCatalogModels = initializeCatalogModels;
@@ -0,0 +1,13 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.SupplierModel = exports.CategoryModel = exports.ProductModel = void 0;
const sequelize_1 = require("sequelize");
class ProductModel extends sequelize_1.Model {
}
exports.ProductModel = ProductModel;
class CategoryModel extends sequelize_1.Model {
}
exports.CategoryModel = CategoryModel;
class SupplierModel extends sequelize_1.Model {
}
exports.SupplierModel = SupplierModel;
@@ -0,0 +1,13 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.initializeCustomerModels = void 0;
const sequelize_1 = require("sequelize");
const customer_models_1 = require("./customer_models");
const initializeCustomerModels = (sequelize) => {
customer_models_1.CustomerModel.init({
id: { type: sequelize_1.DataTypes.INTEGER, autoIncrement: true, primaryKey: true },
name: { type: sequelize_1.DataTypes.STRING },
email: { type: sequelize_1.DataTypes.STRING }
}, { sequelize });
};
exports.initializeCustomerModels = initializeCustomerModels;
@@ -0,0 +1,7 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.CustomerModel = void 0;
const sequelize_1 = require("sequelize");
class CustomerModel extends sequelize_1.Model {
}
exports.CustomerModel = CustomerModel;
@@ -0,0 +1,16 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.initializeModels = exports.SupplierModel = exports.CategoryModel = exports.ProductModel = void 0;
const catalog_helpers_1 = require("./catalog_helpers");
const customer_helpers_1 = require("./customer_helpers");
const order_helpers_1 = require("./order_helpers");
var catalog_models_1 = require("./catalog_models");
Object.defineProperty(exports, "ProductModel", { enumerable: true, get: function () { return catalog_models_1.ProductModel; } });
Object.defineProperty(exports, "CategoryModel", { enumerable: true, get: function () { return catalog_models_1.CategoryModel; } });
Object.defineProperty(exports, "SupplierModel", { enumerable: true, get: function () { return catalog_models_1.SupplierModel; } });
const initializeModels = (sequelize) => {
(0, catalog_helpers_1.initializeCatalogModels)(sequelize);
(0, customer_helpers_1.initializeCustomerModels)(sequelize);
(0, order_helpers_1.initializeOrderModels)(sequelize);
};
exports.initializeModels = initializeModels;
@@ -0,0 +1,30 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.initializeOrderModels = void 0;
const sequelize_1 = require("sequelize");
const order_models_1 = require("./order_models");
const customer_models_1 = require("./customer_models");
const _1 = require(".");
const primaryKey = {
id: { type: sequelize_1.DataTypes.INTEGER, autoIncrement: true, primaryKey: true }
};
const initializeOrderModels = (sequelize) => {
order_models_1.OrderModel.init({
...primaryKey, shipped: sequelize_1.DataTypes.BOOLEAN
}, { sequelize });
order_models_1.ProductSelectionModel.init({
...primaryKey,
quantity: sequelize_1.DataTypes.INTEGER, price: sequelize_1.DataTypes.DECIMAL(10, 2)
}, { sequelize });
order_models_1.AddressModel.init({
...primaryKey,
street: sequelize_1.DataTypes.STRING, city: sequelize_1.DataTypes.STRING,
state: sequelize_1.DataTypes.STRING, zip: sequelize_1.DataTypes.STRING,
}, { sequelize });
order_models_1.OrderModel.belongsTo(customer_models_1.CustomerModel, { as: "customer" });
order_models_1.OrderModel.belongsTo(order_models_1.AddressModel, { foreignKey: "addressId", as: "address" });
order_models_1.OrderModel.belongsToMany(order_models_1.ProductSelectionModel, { through: "OrderProductJunction",
foreignKey: "orderId", as: "selections" });
order_models_1.ProductSelectionModel.belongsTo(_1.ProductModel, { as: "product" });
};
exports.initializeOrderModels = initializeOrderModels;
@@ -0,0 +1,13 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.AddressModel = exports.ProductSelectionModel = exports.OrderModel = void 0;
const sequelize_1 = require("sequelize");
class OrderModel extends sequelize_1.Model {
}
exports.OrderModel = OrderModel;
class ProductSelectionModel extends sequelize_1.Model {
}
exports.ProductSelectionModel = ProductSelectionModel;
class AddressModel extends sequelize_1.Model {
}
exports.AddressModel = AddressModel;
@@ -0,0 +1,24 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.AddOrderQueries = void 0;
const order_models_1 = require("./models/order_models");
const customer_models_1 = require("./models/customer_models");
const queryConfig = {
include: [
{ model: order_models_1.AddressModel, as: "address" },
{ model: customer_models_1.CustomerModel, as: "customer" }
],
raw: true, nest: true
};
function AddOrderQueries(Base) {
return class extends Base {
getOrder(id) {
return order_models_1.OrderModel.findByPk(id, queryConfig);
}
getOrders(excludeShipped) {
return order_models_1.OrderModel.findAll(excludeShipped ?
{ ...queryConfig, where: { shipped: false } } : queryConfig);
}
};
}
exports.AddOrderQueries = AddOrderQueries;
@@ -0,0 +1,38 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.AddOrderStorage = void 0;
const order_models_1 = require("./models/order_models");
const customer_models_1 = require("./models/customer_models");
function AddOrderStorage(Base) {
return class extends Base {
storeOrder(order) {
return this.sequelize.transaction(async (transaction) => {
const { id, shipped } = order;
const [stored] = await order_models_1.OrderModel.upsert({ id, shipped }, { transaction });
if (order.customer) {
const [{ id }] = await customer_models_1.CustomerModel.findOrCreate({
where: { email: order.customer.email },
defaults: order.customer,
transaction
});
stored.customerId = id;
}
if (order.address) {
const [{ id }] = await order_models_1.AddressModel.findOrCreate({
where: { ...order.address },
defaults: order.address,
transaction
});
stored.addressId = id;
}
await stored.save({ transaction });
if (order.selections) {
const sels = await order_models_1.ProductSelectionModel.bulkCreate(order.selections, { transaction });
await stored.setSelections(sels, { transaction });
}
return stored;
});
}
};
}
exports.AddOrderStorage = AddOrderStorage;
@@ -0,0 +1,49 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.AddQueries = void 0;
const models_1 = require("./models");
const sequelize_1 = require("sequelize");
function AddQueries(Base) {
return class extends Base {
async getProducts(params) {
const opts = {};
if (params?.page && params.pageSize) {
opts.limit = params?.pageSize,
opts.offset = (params.page - 1) * params.pageSize;
}
if (params?.searchTerm) {
const searchOp = { [sequelize_1.Op.like]: "%" + params.searchTerm + "%" };
opts.where = {
[sequelize_1.Op.or]: { name: searchOp, description: searchOp }
};
}
if (params?.category) {
opts.where = {
...opts.where, categoryId: params.category
};
}
const result = await models_1.ProductModel.findAndCountAll({
include: [
{ model: models_1.SupplierModel, as: "supplier" },
{ model: models_1.CategoryModel, as: "category" }
],
raw: true, nest: true,
...opts
});
const categories = await this.getCategories();
return { products: result.rows, totalCount: result.count, categories };
}
getCategories() {
return models_1.CategoryModel.findAll({ raw: true, nest: true });
}
getSuppliers() {
return models_1.SupplierModel.findAll({ raw: true, nest: true });
}
getProductDetails(ids) {
return models_1.ProductModel.findAll({
where: { id: { [sequelize_1.Op.in]: ids } }, raw: true, nest: true,
});
}
};
}
exports.AddQueries = AddQueries;
@@ -0,0 +1,37 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.AddStorage = void 0;
const models_1 = require("./models");
function AddStorage(Base) {
return class extends Base {
storeProduct(p) {
return this.sequelize.transaction(async (transaction) => {
if (p.category) {
p.category = await this.storeCategory(p.category);
}
if (p.supplier) {
p.supplier = await this.storeSupplier(p.supplier);
}
const [stored] = await models_1.ProductModel.upsert({
id: p.id, name: p.name, description: p.description,
price: p.price, categoryId: p.category?.id,
supplierId: p.supplier?.id
}, { transaction });
return stored;
});
}
async storeCategory(c, transaction) {
const [stored] = await models_1.CategoryModel.upsert({
id: c.id, name: c.name
}, { transaction });
return stored;
}
async storeSupplier(s, transaction) {
const [stored] = await models_1.SupplierModel.upsert({
id: s.id, name: s.name
}, { transaction });
return stored;
}
};
}
exports.AddStorage = AddStorage;
@@ -0,0 +1,30 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.no_op = exports.required = exports.email = exports.minLength = void 0;
const validator_1 = __importDefault(require("validator"));
const minLength = (min) => (status) => {
if (!validator_1.default.isLength(status.value, { min })) {
status.setInvalid(true);
status.messages.push(`Enter at least ${min} characters`);
}
};
exports.minLength = minLength;
const email = (status) => {
if (!validator_1.default.isEmail(status.value)) {
status.setInvalid(true);
status.messages.push("Enter an email address");
}
};
exports.email = email;
const required = (status) => {
if (validator_1.default.isEmpty(status.value.toString(), { ignore_whitespace: true })) {
status.setInvalid(true);
status.messages.push("A value is required");
}
};
exports.required = required;
const no_op = (status) => { };
exports.no_op = no_op;
@@ -0,0 +1,20 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __exportStar = (this && this.__exportStar) || function(m, exports) {
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
};
Object.defineProperty(exports, "__esModule", { value: true });
__exportStar(require("./validation_types"), exports);
__exportStar(require("./validator"), exports);
__exportStar(require("./basic_rules"), exports);
__exportStar(require("./order_rules"), exports);
@@ -0,0 +1,15 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.AddressValidator = exports.CustomerValidator = void 0;
const validator_1 = require("./validator");
const basic_rules_1 = require("./basic_rules");
exports.CustomerValidator = new validator_1.Validator({
name: [basic_rules_1.required, (0, basic_rules_1.minLength)(6)],
email: basic_rules_1.email
});
exports.AddressValidator = new validator_1.Validator({
street: basic_rules_1.required,
city: basic_rules_1.required,
state: basic_rules_1.required,
zip: basic_rules_1.no_op
});

Some files were not shown because too many files have changed in this diff Show More