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,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,7 @@
export interface Customer {
id?: number;
name: string;
email: string;
federatedId?: string;
}
@@ -0,0 +1,13 @@
import { Customer } from "./customer_models";
import { Address } from "./order_models";
export interface CustomerRepository {
getCustomer(id: number) : Promise<Customer | null>;
getCustomerByFederatedId(id: string): Promise<Customer | null>;
getCustomerAddress(id: number): Promise<Address | null>;
storeCustomer(customer: Customer): Promise<Customer>;
}
@@ -0,0 +1,10 @@
import { CatalogRepository } from "./catalog_repository";
import { CatalogRepoImpl} from "./orm";
import { OrderRepository } from "./order_repository";
import { CustomerRepository } from "./customer_repository";
const repo = new CatalogRepoImpl();
export const catalog_repository: CatalogRepository = repo;
export const order_repository: OrderRepository = repo;
export const customer_repository: CustomerRepository = repo;
@@ -0,0 +1,27 @@
import { Product } from "./catalog_models";
import { Customer } from "./customer_models";
export interface Order {
id?: number;
customer?: Customer;
selections?: ProductSelection[];
address?: Address;
shipped: boolean;
}
export interface ProductSelection {
id?: number;
productId?: number;
quantity: number;
price: number;
}
export interface Address {
id?: number;
street: string;
city: string;
state: string;
zip: string;
}
@@ -0,0 +1,10 @@
import { Order } from "./order_models";
export interface OrderRepository {
getOrder(id: number): Promise<Order| null>;
getOrders(excludeShipped: boolean): Promise<Order[]>;
storeOrder(order: Order): Promise<Order>;
}
@@ -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,51 @@
import { Customer } from "../customer_models";
import { CustomerRepository } from "../customer_repository";
import { Address } from "../order_models";
import { BaseRepo, Constructor } from "./core"
import { CustomerModel } from "./models/customer_models";
import { AddressModel, OrderModel } from "./models/order_models";
export function AddCustomers<TBase extends
Constructor<BaseRepo>>(Base: TBase) {
return class extends Base implements CustomerRepository {
getCustomer(id: number): Promise<Customer | null> {
return CustomerModel.findByPk(id, {
raw: true
});
}
getCustomerByFederatedId(id: string): Promise<Customer | null> {
return CustomerModel.findOne({
where: { federatedId: id },
raw: true
})
}
getCustomerAddress(id: number): Promise<Address | null> {
return AddressModel.findOne({
include: [{
model: OrderModel,
where: { customerId: id },
attributes: []
}],
order: [["updatedAt", "DESC"]]
});
}
async storeCustomer(customer: Customer): Promise<Customer> {
const [data, created] = await CustomerModel.findOrCreate({
where: { email: customer.email },
defaults: customer,
});
if (!created) {
data.name = customer.name;
data.email = customer.email;
data.federatedId = customer.federatedId;
await data.save();
}
return data;
}
}
}
@@ -0,0 +1,12 @@
import { BaseRepo } from "./core";
import { AddQueries } from "./queries";
import { AddStorage } from "./storage";
import { AddOrderQueries } from "./order_queries";
import { AddOrderStorage } from "./order_storage";
import { AddCustomers } from "./customers";
const CatalogRepo = AddStorage(AddQueries(BaseRepo));
const RepoWithOrders = AddOrderStorage(AddOrderQueries(CatalogRepo));
const RepoWithCustomers = AddCustomers(RepoWithOrders);
export const CatalogRepoImpl = RepoWithCustomers;
@@ -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,12 @@
import { DataTypes, Sequelize } from "sequelize";
import { CustomerModel } from "./customer_models";
export const initializeCustomerModels = (sequelize: Sequelize) => {
CustomerModel.init({
id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true},
name: { type: DataTypes.STRING},
email: { type: DataTypes.STRING },
federatedId: { type: DataTypes.STRING }
}, { sequelize})
}
@@ -0,0 +1,13 @@
import { Model, CreationOptional, InferAttributes, InferCreationAttributes }
from "sequelize";
import { Customer } from "../../customer_models";
export class CustomerModel extends Model<InferAttributes<CustomerModel>,
InferCreationAttributes<CustomerModel>> implements Customer {
declare id?: CreationOptional<number>;
declare name: string;
declare email: string;
declare federatedId?: string;
}
@@ -0,0 +1,12 @@
import { Sequelize } from "sequelize";
import { initializeCatalogModels } from "./catalog_helpers";
import { initializeCustomerModels } from "./customer_helpers";
import { initializeOrderModels } from "./order_helpers";
export { ProductModel, CategoryModel, SupplierModel } from "./catalog_models";
export const initializeModels = (sequelize: Sequelize) => {
initializeCatalogModels(sequelize);
initializeCustomerModels(sequelize);
initializeOrderModels(sequelize);
}
@@ -0,0 +1,37 @@
import { DataTypes, Sequelize } from "sequelize";
import { OrderModel, ProductSelectionModel, AddressModel }
from "./order_models";
import { CustomerModel } from "./customer_models";
import { ProductModel } from ".";
const primaryKey = {
id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true }
};
export const initializeOrderModels = (sequelize: Sequelize) => {
OrderModel.init({
...primaryKey, shipped: DataTypes.BOOLEAN
}, {sequelize});
ProductSelectionModel.init({
...primaryKey,
quantity: DataTypes.INTEGER, price: DataTypes.DECIMAL(10, 2)
}, {sequelize});
AddressModel.init({
...primaryKey,
street: DataTypes.STRING, city: DataTypes.STRING,
state: DataTypes.STRING, zip: DataTypes.STRING,
}, {sequelize});
OrderModel.belongsTo(CustomerModel, { as: "customer"});
OrderModel.belongsTo(AddressModel,
{foreignKey: "addressId", as: "address"});
OrderModel.belongsToMany(ProductSelectionModel,
{ through: "OrderProductJunction",
foreignKey: "orderId", as: "selections" });
ProductSelectionModel.belongsTo(ProductModel, { as: "product"});
AddressModel.hasMany(OrderModel, { foreignKey: "addressId"});
}
@@ -0,0 +1,52 @@
import { Model, CreationOptional, ForeignKey, InferAttributes,
InferCreationAttributes,
HasManySetAssociationsMixin} from "sequelize";
import { ProductModel } from "./catalog_models";
import { CustomerModel } from "./customer_models";
import { Address, Order, ProductSelection } from "../../order_models";
export class OrderModel extends Model<InferAttributes<OrderModel>,
InferCreationAttributes<OrderModel>> implements Order {
declare id?: CreationOptional<number>;
declare shipped: boolean;
declare customerId: ForeignKey<CustomerModel["id"]>;
declare customer?: InferAttributes<CustomerModel>
declare addressId: ForeignKey<AddressModel["id"]>;
declare address?: InferAttributes<AddressModel>;
declare selections?: InferAttributes<ProductSelectionModel>[];
declare setSelections:
HasManySetAssociationsMixin<ProductSelectionModel, number>;
}
export class ProductSelectionModel extends
Model<InferAttributes<ProductSelectionModel>,
InferCreationAttributes<ProductSelectionModel>>
implements ProductSelection {
declare id?: CreationOptional<number>;
declare productId: ForeignKey<ProductModel["id"]>;
declare product?: InferAttributes<ProductModel>
declare quantity: number;
declare price: number;
declare orderId: ForeignKey<OrderModel["id"]>;
declare order?: InferAttributes<OrderModel>;
}
export class AddressModel extends Model<InferAttributes<AddressModel>,
InferCreationAttributes<AddressModel>> implements Address {
declare id?: CreationOptional<number>;
declare street: string;
declare city: string;
declare state: string;
declare zip: string;
}
@@ -0,0 +1,31 @@
import { Attributes, FindOptions } from "sequelize";
import { Order } from "../order_models"
import { BaseRepo, Constructor } from "./core"
import { AddressModel, OrderModel } from "./models/order_models";
import { CustomerModel } from "./models/customer_models";
const queryConfig: FindOptions<Attributes<OrderModel>> = {
include: [
{ model: AddressModel, as: "address"},
{ model: CustomerModel, as: "customer" }
],
raw: true, nest: true
}
export function AddOrderQueries<TBase
extends Constructor<BaseRepo>>(Base: TBase) {
return class extends Base {
getOrder(id: number) : Promise<Order | null> {
return OrderModel.findByPk(id, queryConfig);
}
getOrders(excludeShipped: boolean): Promise<Order[]> {
return OrderModel.findAll(
excludeShipped ?
{ ...queryConfig, where: { shipped: false}} : queryConfig
)
}
}
}
@@ -0,0 +1,47 @@
import { Order } from "../order_models"
import { BaseRepo, Constructor } from "./core"
import { AddressModel, OrderModel, ProductSelectionModel }
from "./models/order_models";
import { CustomerModel } from "./models/customer_models";
export function AddOrderStorage<TBase extends
Constructor<BaseRepo>>(Base: TBase) {
return class extends Base {
storeOrder(order: Order): Promise<Order> {
return this.sequelize.transaction(async (transaction) => {
const { id, shipped } = order;
const [stored] =
await OrderModel.upsert({ id, shipped }, {transaction});
if (order.customer) {
const [{id}] = await CustomerModel.findOrCreate({
where: { email: order.customer.email},
defaults: order.customer,
transaction
});
stored.customerId = id;
}
if (order.address) {
const [{id}] = await AddressModel.findOrCreate({
where: { ...order.address },
defaults: order.address,
transaction
});
stored.addressId = id;
}
await stored.save({transaction});
if (order.selections) {
const sels = await ProductSelectionModel.bulkCreate(
order.selections, { transaction});
await stored.setSelections(
sels, { transaction });
}
return stored;
});
}
}
}
@@ -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,25 @@
import validator from "validator";
import { ValidationStatus } from "./validation_types";
export const minLength = (min: number) => (status: ValidationStatus) => {
if (!validator.isLength(status.value, { min })) {
status.setInvalid(true);
status.messages.push(`Enter at least ${min} characters`);
}
};
export const email = (status: ValidationStatus) => {
if (!validator.isEmail(status.value)) {
status.setInvalid(true);
status.messages.push("Enter an email address");
}
};
export const required = (status: ValidationStatus) => {
if (validator.isEmpty(status.value.toString(), { ignore_whitespace: true})) {
status.setInvalid(true);
status.messages.push("A value is required");
}
};
export const no_op = (status: ValidationStatus) => { /* do nothing */ }
@@ -0,0 +1,4 @@
export * from "./validation_types";
export * from "./validator";
export * from "./basic_rules";
export * from "./order_rules";
@@ -0,0 +1,17 @@
import { Validator } from "./validator";
import { required, minLength, email, no_op } from "./basic_rules";
import { Address } from "../order_models";
import { Customer } from "../customer_models";
export const CustomerValidator = new Validator<Customer>({
name: [required, minLength(6)],
email: email,
federatedId: no_op
});
export const AddressValidator = new Validator<Address>({
street: required,
city: required,
state: required,
zip: no_op
});
@@ -0,0 +1,26 @@
export class ValidationStatus {
private invalid: boolean = false;
constructor(public readonly value: any) {}
get isInvalid() : boolean {
return this.invalid
}
setInvalid(newValue: boolean) {
this.invalid = newValue || this.invalid;
}
messages: string[] = [];
}
export type ValidationRule = (status: ValidationStatus)
=> void | Promise<void>;
export type ValidationRuleSet<T> = {
[key in keyof Omit<Required<T>, "id">]: ValidationRule | ValidationRule[];
}
export type ValidationResults<T> = {
[key in keyof Omit<Required<T>, "id">]: ValidationStatus;
}
@@ -0,0 +1,47 @@
import { ValidationResults, ValidationRule, ValidationRuleSet,
ValidationStatus } from "./validation_types";
export class Validator<T>{
constructor(public rules: ValidationRuleSet<T>,
public breakOnInvalid = true) {}
async validate(data: any): Promise<ValidationResults<T>> {
const vdata = Object.entries(this.rules).map(async ([key, rules]) => {
const status = new ValidationStatus(data?.[key] ?? "");
const rs = (Array.isArray(rules) ? rules: [rules]);
for (const r of rs) {
if (!status.isInvalid || !this.breakOnInvalid) {
await r(status);
}
}
return [key, status];
});
const done = await Promise.all(vdata);
return Object.fromEntries(done);
}
validateOriginal(data: any): ValidationResults<T> {
const vdata = Object.entries(this.rules).map(([key, rules]) => {
const status = new ValidationStatus(data?.[key] ?? "");
(Array.isArray(rules) ? rules: [rules])
.forEach(async (rule: ValidationRule) => {
if (!status.isInvalid || !this.breakOnInvalid) {
await rule(status);
}
});
return [key, status];
});
return Object.fromEntries(vdata);
}
}
export function isValid<T>(result: ValidationResults<T>) {
return Object.values<ValidationStatus>(result)
.every(r => r.isInvalid === false);
}
export function getData<T>(result: ValidationResults<T>): T {
return Object.fromEntries (Object.entries<ValidationStatus>(result)
.map(([key, status]) => [key, status.value])) as T;
}