diff --git a/src/webserver/controllers/loginController.ts b/src/webserver/controllers/loginController.ts new file mode 100644 index 0000000..1718ced --- /dev/null +++ b/src/webserver/controllers/loginController.ts @@ -0,0 +1,44 @@ +import cookie from "cookie"; +import type { Request, Response, NextFunction } from "express"; + +const cookieOptions = { + path: "/", + maxAge: 60 * 60 * 24 * 7, +}; + +function addAdminCookie(res: Response) { + const adminCookie = cookie.serialize("admin", true, cookieOptions); + res.setHeader("Set-Cookie", adminCookie); +} + +function deleteAdminCookie(res: Response) { + const adminCookie = cookie.serialize("admin", false, { + path: "/", + maxAge: 1, + }); + res.setHeader("Set-Cookie", adminCookie); +} + +function login(req: Request, res: Response) { + const { username, password } = req.body; + + if (username !== "admin" || password !== "admin") { + return res.status(403).send({ + success: false, + message: "Feil brukernavn eller passord", + }); + } + + addAdminCookie(res); + res.send({ + success: true, + message: "Velkommen!", + }); +} + +function logout(req: Request, res: Response) { + deleteAdminCookie(res); + res.status(200).send("ok"); +} + +export default { login, logout }; diff --git a/src/webserver/controllers/orderController.ts b/src/webserver/controllers/orderController.ts new file mode 100644 index 0000000..c4854a4 --- /dev/null +++ b/src/webserver/controllers/orderController.ts @@ -0,0 +1,381 @@ +import logger from "../../logger"; +import OrderRepository from "../../order"; +import CustomerRepository from "../../customer"; +import ProductRepository from "../../product"; +import WarehouseRepository from "../../warehouse"; +import Cart from "../../cart/Cart"; + +import ICustomer from "../../interfaces/ICustomer"; + +import { validEmail } from "../../utils/formValidation"; +import ICart from "../../interfaces/ICart"; + +const orderRepository = new OrderRepository(); +const customerRepository = new CustomerRepository(); +const productRepository = new ProductRepository(); +const warehouseRepository = new WarehouseRepository(); + +async function validateCart(cart: ICart[]) { + const validationErrors = []; + + for (let i = 0; i < cart.length; i++) { + const { sku_id, quantity, name, lineitem_id } = cart[i]; + const product = await productRepository.get(sku_id); + if (!product) { + validationErrors.push({ + type: "order-summary", + field: `lineitem-${lineitem_id}`, + message: `Fant ikke produktet ${name}.`, + }); + } + + // check if in stock + const leftInStockResponse = await warehouseRepository.checkSkuStock(sku_id); + if (!leftInStockResponse || quantity > leftInStockResponse?.stock) { + validationErrors.push({ + type: "order-summary", + field: `lineitem-${lineitem_id}`, + message: `Det er bare ${leftInStockResponse?.stock} igjen av denne varen på lager.`, + }); + } + } + + return validationErrors; +} + +function validateCustomer(customer: ICustomer) { + const { + email, + first_name, + last_name, + street_address, + zip_code, + city, + } = customer; + const validationErrors = []; + + if (!email?.length) { + validationErrors.push({ + type: "customer", + field: "email", + message: "Epost adresse er påkrevd", + }); + } + + if (!validEmail(email)) { + validationErrors.push({ + type: "customer", + field: "email", + message: "Epost addressen er ikke gyldig", + }); + } + + if (!first_name.length) { + validationErrors.push({ + type: "customer", + field: "first_name", + message: "Fornavn er påkrevd", + }); + } + + if (!last_name.length) { + validationErrors.push({ + type: "customer", + field: "last_name", + message: "Etternavn er påkrevd", + }); + } + + if (!street_address.length) { + validationErrors.push({ + type: "customer", + field: "street_address", + message: "Gateadresse er påkrevd", + }); + } + + const _zipcode = String(zip_code || ""); + if (!_zipcode.length) { + validationErrors.push({ + type: "customer", + field: "zip_code", + message: "Postnummer er påkrevd", + }); + } else if (_zipcode.length !== 3 && _zipcode.length !== 4) { + validationErrors.push({ + type: "customer", + field: "zip_code", + message: "Postnummer må være 4 siffer", + }); + } + + if (!city.length) { + validationErrors.push({ + type: "customer", + field: "city", + message: "By er påkrevd", + }); + } + + return validationErrors; +} + +async function getAll(req, res) { + logger.info("Getting all orders"); + try { + const orders = await orderRepository.getAll(); + res.send({ + success: true, + orders, + }); + } catch (error) { + logger.error("Error while getting all orders", { error }); + res.statusCode = error?.statusCode || 500; + + res.send({ + success: false, + message: "Unexpected error while getting all orders", + }); + } +} + +// interface IOrderFormError { +// type: string (enum) +// field: string (enum) // the field is linked to html form name +// message: string +// } + +async function createOrder(req, res) { + const cart: ICart[] = req.body?.cart; + const customer: ICustomer = req.body?.customer; + + logger.info("Submitting new order", { customer, cart }); + + // check if product exists + let validationErrors = []; + try { + validationErrors = validationErrors.concat(validateCustomer(customer)); + validationErrors = validationErrors.concat(await validateCart(cart)); + } catch (error) { + logger.error("Error while validation order", { error }); + res.statusCode = error?.statusCode || 500; + + return res.send({ + success: false, + form_input: null, + message: error?.message || "Unable to validate order", + }); + } + + if (validationErrors.length) { + logger.error("Validation error when submitting order", { + validationErrors, + }); + res.statusCode = 400; + + return res.send({ + success: false, + validationErrors, + }); + } + + try { + const { customer_no } = await customerRepository.newCustomer(customer); + const { order_id } = await orderRepository.newOrder(customer_no); + await Promise.all( + cart.map((lineItem) => + orderRepository.addOrderLineItem( + order_id, + lineItem.product_no, + lineItem.sku_id, + lineItem.price, + lineItem.quantity + ) + ) + ); + + logger.info("Sucessfully created order", { order_id, customer_no }); + return res.send({ + success: true, + message: "Sucessfull created order!", + order_id, + customer_no, + }); + } catch (error) { + logger.error("Error while creating customer or order", { error }); + res.statusCode = error?.statusCode || 500; + + return res.send({ + success: false, + message: error?.message || "Unexpected error while creating order", + }); + } +} + +async function get(req, res) { + const { order_id } = req.params; + logger.info("Getting order by id", { order_id }); + + // get a order + let order = null; + try { + order = await orderRepository.getOrderDetailed(order_id); + logger.info("Found order", { order }); + + res.send({ + success: true, + order, + }); + } catch (error) { + logger.error("Error while looking for order", { order_id, error }); + res.statusCode = error.statusCode || 500; + + res.send({ + success: false, + message: error?.message || "Unexpected error while getting order", + }); + } +} + +async function getOrderStatus(req, res) { + const { order_id } = req.params; + logger.info("Getting order status by id", { order_id }); + + return await orderRepository.getOrderStatus(order_id).then((orderStatus) => { + logger.info("Found order status", { order_id, orderStatus }); + + if (orderStatus) { + res.send({ + status: orderStatus?.status, + order_id, + success: true, + }); + } else { + logger.error("Error while getting order status", { order_id }); + + res.status(500).send({ + initiated: null, + confirmed: null, + message: "Unexpeted error! Unable to get order status", + success: false, + }); + } + }); +} + +// async function cancelOrder(req, res) { +// const { id } = req.params; +// let orderId = id; +// const vippsId = id; +// await vippsRepository.getOrder(id).then((order) => { +// if (order && order.parent_order_id) { +// orderId = order.parent_order_id; +// } +// }); +// return vippsRepository +// .cancelOrRefundPartialOrder(vippsId, req.id) +// .then((order) => PlsController.turnOff(order)) +// .then((_) => +// orderRepository.cancelOrder(orderId).then((canceled) => +// res.send({ +// success: true, +// canceled: canceled, +// }) +// ) +// ) +// .catch((error) => { +// throw error; +// }); +// } + +// function verifyNoCollidingOrders(id) { +// return orderRepository +// .getOrder(id) +// .then((order) => orderRepository.getConflictingProductOrders(order)) +// .then(({ order, conflicting }) => checkForConflicting(order, conflicting)); +// } + +// function checkForConflicting(order, conflicting) { +// const thisOrderCreated = new Date(order.created); +// for (var i = 0; i < conflicting.length; i++) { +// const thisConflictinCreated = new Date(conflicting[i].created); +// // if we are anywhere with a conflict, we need to cancel/refund this +// if (thisOrderCreated > thisConflictinCreated) { +// throw new WagoError( +// WAGO_ERROR_STATUS_CODES.CONFLICTING_ORDER_RESERVATION +// ); +// } +// } +// return order; +// } + +// async function extendOrder(req, res) { +// let { amount } = req.body; +// let extendeeOrderId = req.params.id; +// let extendedTimes = 1; +// const previousExtendedAndPreviousOrder = await vippsRepository +// .getOrder(extendeeOrderId) +// .then((order) => { +// if (order.parent_order_id) { +// extendeeOrderId = order.parent_order_id; +// } +// return orderRepository.getExtendedOrders(extendeeOrderId); +// }); + +// let orderId = `${extendeeOrderId}-ext-`; +// if (!previousExtendedAndPreviousOrder || amount < 0) { +// res.status(404).send({ success: false }); +// return; +// } + +// for (let i = 0; i < previousExtendedAndPreviousOrder.length; i++) { +// const currentElement = previousExtendedAndPreviousOrder[i]; +// if (currentElement.order_id !== extendeeOrderId) { +// extendedTimes += 1; +// } +// } + +// orderId += extendedTimes; +// const orderWithProductData = await orderRepository.getOrderWithProduct( +// extendeeOrderId +// ); + +// try { +// amount = parseFloat(amount); +// } catch (e) {} +// const moneyToPay = orderWithProductData.price * 100 * amount; + +// return vippsRepository +// .newExtendedPayment( +// orderId, +// extendeeOrderId, +// moneyToPay, +// amount, +// orderWithProductData +// ) +// .then((resp) => { +// res.send({ +// success: true, +// ...resp, +// }); +// }) +// .catch((error) => { +// res.statusCode = error.statusCode || 500; + +// res.send({ +// success: false, +// ...error, +// }); +// }); +// } + +export default { + createOrder, + get, + getAll, + getOrderStatus, + // cancelOrder, + extendOrder: () => {}, + checkForConflicting: () => {}, + verifyNoCollidingOrders: () => {}, +}; diff --git a/src/webserver/controllers/productController.ts b/src/webserver/controllers/productController.ts new file mode 100644 index 0000000..0022414 --- /dev/null +++ b/src/webserver/controllers/productController.ts @@ -0,0 +1,237 @@ +import logger from "../../logger"; +import ProductRepository from "../../product"; +const productRepository = new ProductRepository(); +import type { Request, Response } from "express"; + +async function add(req: Request, res: Response) { + logger.info("Adding new product"); + try { + const productId = await productRepository.add(); + const product = await productRepository.get(productId); + logger.info("New product", { product }); + + return res.send({ + success: true, + product, + }); + } catch (error) { + logger.error("Error while adding product", { error }); + res.statusCode = error.statusCode || 500; + + return res.send({ + success: false, + message: error?.message || "Unexpected error while adding product", + }); + } +} + +function update(req: Request, res: Response) { + const { product_id } = req.params; + logger.info("Updating product", { product_id }); + + return productRepository + .get(product_id) + .then((product) => { + logger.info("Updated product", { product, product_id }); + + res.send({ + success: true, + product: product, + }); + }) + .catch((error) => { + logger.error("Error while updating product", { error, product_id }); + res.statusCode = error.statusCode || 500; + return res.send({ + success: false, + message: error?.message || "Unexpected error while updating product", + }); + }); +} + +function getAll(req: Request, res: Response) { + logger.info("Getting all products"); + + return productRepository + .getAllProducts() + .then((products) => { + logger.info("Found products", { products }); + + res.send({ + success: true, + products: products, + }); + }) + .catch((error) => { + logger.error("Error while getting all products", { error }); + res.statusCode = error.statusCode || 500; + + res.send({ + success: false, + message: + error?.message || "Unexpected error while getting all products", + }); + }); +} + +function getById(req: Request, res: Response) { + const { product_id } = req.params; + logger.info("Getting product", { product_id }); + + return productRepository + .get(product_id) + .then((product) => { + logger.info("Found product", { product, product_id }); + + res.send({ + success: true, + product: product, + }); + }) + .catch((error) => { + logger.error("Error while getting product by id", { product_id }); + res.statusCode = error.statusCode || 500; + + res.send({ + success: false, + message: + error?.message || "Unexpected error while getting product by id", + }); + }); +} + +async function addSku(req: Request, res: Response) { + const { product_id } = req.params; + logger.info("Adding new sku", { product_id }); + + try { + await productRepository.addSku(product_id); + const skus = await productRepository.getSkus(product_id); + + if (!skus.find((sku) => sku.default_price === true)) { + const setDefaultResponse = await productRepository.setSkuDefaultPrice( + product_id, + skus[skus.length - 1].sku_id + ); + + skus[skus.length - 1].default_price = true; + } + logger.info("New skus after add", { skus, product_id }); + + res.send({ + success: true, + skus, + }); + } catch (error) { + logger.error("Error adding sku", { error, product_id }); + res.statusCode = error?.statusCode || 500; + res.send({ + success: false, + message: error?.message || "Unexpected error while adding new sku", + }); + } +} + +async function getSkus(req: Request, res: Response) { + const { product_id } = req.params; + const skus = await productRepository.getSkus(product_id); + + return res.send({ + success: true, + skus, + }); +} + +async function updateSku(req: Request, res: Response) { + const { product_id, sku_id } = req.params; + const { stock, size, price } = req.body; + logger.info("Updating sku", { product_id, sku_id, stock, price, size }); + + try { + await productRepository.updateSku(product_id, sku_id, stock, size, price); + const skus = await productRepository.getSkus(product_id); + logger.info("New skus after update", { skus, product_id, sku_id }); + + res.send({ + success: true, + skus, + }); + } catch (error) { + logger.error("Error updating sku", { product_id, sku_id, error }); + res.statusCode = error?.statusCode || 500; + + res.send({ + success: false, + message: error?.message || "Unexpected error while updating sku", + }); + } +} + +async function deleteSku(req: Request, res: Response) { + const { product_id, sku_id } = req.params; + + try { + await productRepository.deleteSku(product_id, sku_id); + const skus = await productRepository.getSkus(product_id); + logger.info("New skus after delete", { skus, product_id, sku_id }); + + res.send({ + success: true, + skus, + }); + } catch (error) { + logger.error("Error deleting sku", { product_id, sku_id, error }); + res.statusCode = error?.statusCode || 500; + + res.send({ + success: false, + message: error?.message || "Unexpected error while deleting sku", + }); + } +} + +async function setSkuDefaultPrice(req: Request, res: Response) { + const { product_id, sku_id } = req.params; + logger.info("Updating sku default price", { product_id, sku_id }); + + try { + await productRepository.setSkuDefaultPrice(product_id, sku_id); + const skus = await productRepository.getSkus(product_id); + logger.info("New skus after update default price", { + skus, + product_id, + sku_id, + }); + + res.send({ + success: true, + skus, + }); + } catch (error) { + logger.error("Error while updating sku default price", { + product_id, + sku_id, + error, + }); + res.statusCode = error?.statusCode || 500; + + res.send({ + success: false, + message: + error?.message || + "Unexpected error while updating default price for sku", + }); + } +} + +export default { + add, + update, + getAll, + getById, + addSku, + getSkus, + updateSku, + deleteSku, + setSkuDefaultPrice, +}; diff --git a/src/webserver/controllers/stripePaymentController.ts b/src/webserver/controllers/stripePaymentController.ts new file mode 100644 index 0000000..2917ba2 --- /dev/null +++ b/src/webserver/controllers/stripePaymentController.ts @@ -0,0 +1,94 @@ +import Configuration from "../../config/configuration"; +import StripeApi from "../../stripe/stripeApi"; +import StripeRepository from "../../stripe/stripeRepository"; +import OrderRepository from "../../order"; +import CustomerRepository from "../../customer"; + +import Stripe from "stripe"; + +import type { Request, Response, NextFunction } from "express"; +import type { IOrder, ILineItem } from "../../interfaces/IOrder"; +import type ICustomer from "../../interfaces/ICustomer"; + +const configuration = Configuration.getInstance(); +const stripePublicKey = configuration.get("stripe", "publicKey"); +const stripeSecretKey = configuration.get("stripe", "secretKey"); +const stripeApi = new StripeApi(stripePublicKey, stripeSecretKey); +const stripeRepository = new StripeRepository(); +const orderRepository = new OrderRepository(); +const customerRepository = new CustomerRepository(); + +async function create(req, res) { + const clientId = req?.planetId; + const { order_id, customer_no } = req.body; + + if (!order_id || !customer_no) { + return res.status(400).send({ + success: false, + message: "Missing order_id and/or customer_id", + }); + } + + const order: IOrder = await orderRepository.getOrder(order_id); + const customer: ICustomer = await customerRepository.getCustomer(customer_no); + + const sum = order.lineItems?.reduce( + (total, lineItem: ILineItem) => total + lineItem.quantity * lineItem.price, + 0 + ); + + stripeRepository + .createPayment(clientId, sum, order_id, customer) + .then((clientSecret) => + res.send({ + success: true, + clientSecret, + }) + ); +} + +async function updatePayment(req: Request, res: Response) { + console.log("STRIPE WEBHOOK body:", req.body); + const { type, data } = req.body; + const { object } = data; + const { orderId } = object?.metadata; + + if (!data) { + console.log("no data found in webhook, nothing to do"); + return res.status(200).send("ok"); + } + + if (!orderId) { + console.log("no order_id found in webhook, nothing to do"); + return res.status(200).send("ok"); + } + + if (type === "payment_intent.created") { + console.log("handle payment intent created... doing nothing"); + } else if (type === "payment_intent.succeeded") { + console.log("handle payment succeeded", object); + await stripeRepository.updatePaymentIntent(object); + orderRepository.confirmOrder(orderId); + } else if (type === "payment_intent.payment_failed") { + console.log("handle payment failed", object); + await stripeRepository.updatePaymentIntent(object); + orderRepository.cancelOrder(orderId); + } else if (type === "charge.succeeded") { + console.log("handle charge succeeded", object); + await stripeRepository.updatePaymentCharge(object); + } else if (type === "charge.refunded") { + console.log("handle charge refunded", object); + await stripeRepository.updatePaymentCharge(object); + await orderRepository.refundOrder(orderId); + } else { + console.log(`webhook for ${type}, not setup yet`); + } + + // should always return 200 but should try catch and log error + return res.status(200).send("ok"); +} + +export default { + create, + updatePayment, +}; diff --git a/src/webserver/controllers/vippsPaymentController.js b/src/webserver/controllers/vippsPaymentController.js new file mode 100644 index 0000000..8647a4a --- /dev/null +++ b/src/webserver/controllers/vippsPaymentController.js @@ -0,0 +1,190 @@ +const { VippsApiResourceNotFound } = require(`${__base}/vipps/vippsApiErrors`); +const { WagoError, WAGO_ERROR_STATUS_CODES } = require(`${__base}/errors`); +const { VIPPS_STATUS_CODES } = require(`${__enums}/vipps`); +const OrderRepository = require(`${__base}/order`); +const OrderController = require(`${__controllers}/orderController`); + +const orderRepository = new OrderRepository(); +const Vipps = require(`${__base}/vipps`); +const VippsRepository = require(`${__base}/vipps/vippsRepository`); + +const vipps = new Vipps(); +const vippsRepository = new VippsRepository(); + +function getPaymentDetails(req, res) { + console.log("hit get details payment"); + + const { id } = req.params; + return vipps + .getPayment(id) + .then((payment) => + res.send({ + success: true, + payment, + }) + ) + .catch((err) => { + const { statusCode, message } = err; + + return res.status(statusCode || 500).send({ + success: false, + payment: null, + message: message, + }); + }); +} + +function cancelPayment(req, res) { + console.log("hit cancelOrder endpoint"); + const { id } = req.params; + + return vippsRepository.cancelPayment(id).then((canceled) => + res.send({ + success: true, + canceled: canceled, + }) + ); +} + +function rejectPayment(id, amount) { + console.log("rejectPayment"); + return vippsRepository + .cancelPayment(id) + .then((_) => + vippsRepository.updatePaymentStatus( + id, + VIPPS_STATUS_CODES.REJECTED, + amount + ) + ) + .then((_) => orderRepository.rejectByVippsTimeout(id)); +} + +function rejectByVippsTimeout(id, amount) { + console.log("reject-by-timeout"); + return vippsRepository + .cancelPayment(id) + .then((_) => + vippsRepository.updatePaymentStatus( + id, + VIPPS_STATUS_CODES.TIMED_OUT_REJECT, + amount + ) + ) + .then((_) => orderRepository.rejectByVippsTimeout(id)); +} + +function updatePayment(req, res) { + console.log("updatePayment:", req.body); + const { id } = req.params; + const { + transactionInfo: { status }, + errorInfo, + } = req.body; + + let statusCode; + if (status && VIPPS_STATUS_CODES[status]) { + statusCode = VIPPS_STATUS_CODES[status]; + } + + // TODO: Don't cancel whole order when one new order + // fails/gets rejected/cancels + if (status === VIPPS_STATUS_CODES.REJECTED) { + switch (errorInfo.errorCode) { + case 45: + default: + return vippsRepository + .cancelOrRefundPartialOrder(id) + .then((_) => orderRepository.cancelOrder(id)); + } + } else if (status === VIPPS_STATUS_CODES.CANCELLED) { + return vippsRepository + .cancelOrRefundPartialOrder(id) + .then((_) => orderRepository.cancelOrder(id)); + } + + return vippsRepository + .getOrder(id) + .then((order) => extendOrUpdateNewOrder(order, statusCode, req)) + .then((_) => res.status(200).send()) + .catch((error) => { + console.log("we errored here", error); + return vippsRepository + .cancelOrRefundPartialOrder(id) + .then((_) => orderRepository.cancelOrder(id)); + }); +} + +function extendOrUpdateNewOrder(order, statusCode, req) { + const { + transactionInfo: { transactionId, amount }, + } = req.body; + const vippsId = order.order_id; + let orderId = order.order_id; + if (order.parent_order_id) { + orderId = order.parent_order_id; + } + return vippsRepository + .updatePaymentStatus(vippsId, statusCode, amount, transactionId) + .then((_) => { + if (order.parent_order_id) { + return getNewTimeToTurnOn(order); + // return getNewTimeToTurnOn(order).then(hours => + // PlsController.extendTime(hours, order) + // ); + } + + return OrderController.verifyNoCollidingOrders(orderId); + // return OrderController.verifyNoCollidingOrders(orderId).then( + // async order => await PlsController.sendDuration(order) + // ); + }) + .then(async (plsResponse) => { + if (plsResponse.startTime) { + await orderRepository.updateStartTime(orderId, plsResponse.startTime); + } + return orderRepository + .updateEndTime(orderId, plsResponse.endTime) + .then((_) => + vippsRepository.updateEndTime(vippsId, plsResponse.endTime) + ); + }) + .then((_) => vipps.captureAmount(vippsId, amount, req.id)) + .then((capture) => + vippsRepository.updatePaymentCapture( + vippsId, + capture.transactionSummary.capturedAmount + ) + ); +} + +async function getNewTimeToTurnOn(order) { + const previousExtendedAndPreviousOrder = await orderRepository.getExtendedOrders( + order.parent_order_id + ); + let hoursToExpandAndSendToPls = 0; + let currentEndTime; + const now = new Date(); + + for (let i = 0; i < previousExtendedAndPreviousOrder.length; i++) { + const currentElement = previousExtendedAndPreviousOrder[i]; + + if (currentElement.order_id === order.parent_order_id) { + currentEndTime = new Date(currentElement.end_time); + if (currentEndTime < now) { + currentEndTime = now; + } + } + } + const timeLeft = (currentEndTime.getTime() - now.getTime()) / 36e5; + hoursToExpandAndSendToPls = order.hours + timeLeft; + return hoursToExpandAndSendToPls; +} + +module.exports = { + getPaymentDetails, + cancelPayment, + extendOrUpdateNewOrder, + updatePayment, + getNewTimeToTurnOn, +}; diff --git a/src/webserver/controllers/vippsTokenController.ts b/src/webserver/controllers/vippsTokenController.ts new file mode 100644 index 0000000..cfe00a5 --- /dev/null +++ b/src/webserver/controllers/vippsTokenController.ts @@ -0,0 +1,12 @@ +const Vipps = require(`${__base}/vipps`); +const vipps = new Vipps(); +import type { Request, Response } from "express"; + +export default function VippsTokenController(req: Request, res: Response) { + return vipps.token.then((token) => + res.send({ + success: true, + token: token, + }) + ); +} diff --git a/src/webserver/controllers/warehouseController.ts b/src/webserver/controllers/warehouseController.ts new file mode 100644 index 0000000..1f5a090 --- /dev/null +++ b/src/webserver/controllers/warehouseController.ts @@ -0,0 +1,65 @@ +import logger from "../../logger"; +import WarehouseRepository from "../../warehouse"; +const warehouseRepository = new WarehouseRepository(); +import type { Request, Response } from "express"; + +function getAll(req: Request, res: Response) { + logger.info("Fething all warehouse products"); + + return warehouseRepository + .all() + .then((warehouseProucts) => { + logger.info("Found warehouse products", { products: warehouseProucts }); + + res.send({ + success: true, + warehouse: warehouseProucts, + }); + }) + .catch((error) => { + logger.error("Error fetching warehouse products", { error }); + res.statusCode = error.statusCode || 500; + + res.send({ + success: false, + message: + error?.message || + "Unexpected error while fetching all warehouse products", + }); + }); +} + +function getProduct(req: Request, res: Response) { + const { productId } = req.params; + logger.info("Fetching warehouse product", { product_id: productId }); + + return warehouseRepository + .getProduct(productId) + .then((product) => { + logger.info("Found warehouse product", { + product, + product_id: productId, + }); + + res.send({ + success: true, + product: product, + }); + }) + .catch((error) => { + logger.error("Error fetching warehouse product:", { + error, + product_id: productId, + }); + res.statusCode = error.statusCode || 500; + + res.send({ + success: false, + message: + error?.message || + `Unexpected error while fetching product with id: ${productId}`, + }); + }); +} + +export default { getAll, getProduct }; diff --git a/src/webserver/middleware/getOrSetCookieForClient.ts b/src/webserver/middleware/getOrSetCookieForClient.ts new file mode 100644 index 0000000..92f87c6 --- /dev/null +++ b/src/webserver/middleware/getOrSetCookieForClient.ts @@ -0,0 +1,37 @@ +import cookie from "cookie"; +import generateUUID from "../../utils/generateUUID"; +import httpContext from "express-http-context"; +import type { Request, Response, NextFunction } from "express"; + +const cookieClientKey = "planetId"; +const cookieOptions = { + path: "/", + maxAge: 60 * 60 * 24 * 7, // 7 days +}; + +function setClientIdCookieHeader(res: Response, value: string) { + const setCookie = cookie.serialize("planetId", value, cookieOptions); + return res.setHeader("Set-Cookie", setCookie); +} + +const getOrSetCookieForClient = ( + req: Request, + res: Response, + next: NextFunction +) => { + const cookies = cookie.parse(req.headers.cookie || ""); + const planetId = cookies[cookieClientKey]; + + if (planetId) { + req.planetId = planetId; + httpContext.set("planetId", planetId); + return next(); + } + + const clientId = generateUUID(); + setClientIdCookieHeader(res, clientId); + + next(); +}; + +export default getOrSetCookieForClient; diff --git a/src/webserver/middleware/setupCORS.ts b/src/webserver/middleware/setupCORS.ts new file mode 100644 index 0000000..b6d6267 --- /dev/null +++ b/src/webserver/middleware/setupCORS.ts @@ -0,0 +1,8 @@ +import type { Request, Response, NextFunction } from "express"; + +const openCORS = (req: Request, res: Response, next: NextFunction) => { + res.set("Access-Control-Allow-Origin", "*"); + return next(); +}; + +export default openCORS; diff --git a/src/webserver/middleware/setupHeaders.ts b/src/webserver/middleware/setupHeaders.ts new file mode 100644 index 0000000..5736ea2 --- /dev/null +++ b/src/webserver/middleware/setupHeaders.ts @@ -0,0 +1,43 @@ +import type { Request, Response, NextFunction } from "express"; + +const camelToKebabCase = (str: string) => + str.replace(/[A-Z]/g, (letter: string) => `-${letter.toLowerCase()}`); + +const mapFeaturePolicyToString = (features) => { + return Object.entries(features) + .map(([key, value]) => { + key = camelToKebabCase(key); + value = value == "*" ? value : `'${value}'`; + return `${key} ${value}`; + }) + .join("; "); +}; + +const setupHeaders = (req: Request, res: Response, next: NextFunction) => { + res.set("Access-Control-Allow-Headers", "Content-Type"); + res.set("Access-Control-Allow-Methods", "POST, PATCH, DELETE"); + + // Security + res.set("X-Content-Type-Options", "nosniff"); + res.set("X-XSS-Protection", "1; mode=block"); + res.set("X-Frame-Options", "SAMEORIGIN"); + res.set("X-DNS-Prefetch-Control", "off"); + res.set("X-Download-Options", "noopen"); + res.set("Strict-Transport-Security", "max-age=15552000; includeSubDomains"); + + // Feature policy + const features = { + fullscreen: "*", + payment: "none", + microphone: "none", + camera: "self", + speaker: "*", + syncXhr: "self", + }; + const featureString = mapFeaturePolicyToString(features); + res.set("Feature-Policy", featureString); + + return next(); +}; + +export default setupHeaders; diff --git a/src/webserver/server.ts b/src/webserver/server.ts new file mode 100644 index 0000000..efc0ccb --- /dev/null +++ b/src/webserver/server.ts @@ -0,0 +1,79 @@ +// import * as global from "../types/global"; +import path from "path"; +import express from "express"; +import { createServer } from "http"; + +// services +import logger from "../logger"; +import { setupCartWebsocketServer } from "./websocketCartServer"; + +// controllers +// const TokenController = require(`${__controllers}/tokenController`); +import OrderController from "./controllers/orderController"; +import ProductController from "./controllers/productController"; +import WarehouseController from "./controllers/warehouseController"; +import StripePaymentController from "./controllers/stripePaymentController"; +import LoginController from "./controllers/loginController"; + +// middleware +import httpContext from "express-http-context"; +import setupCORS from "./middleware/setupCORS"; +import setupHeaders from "./middleware/setupHeaders"; +import getOrSetCookieForClient from "./middleware/getOrSetCookieForClient"; + +const app = express(); +const port = process.env.PORT || 30010; + +app.use(httpContext.middleware); +app.use(setupCORS); +app.use(setupHeaders); +app.use(getOrSetCookieForClient); + +// parse application/json + +const router = express.Router(); +router.use(express.json()); + +router.post("/login", LoginController.login); +router.post("/logout", LoginController.logout); + +router.get("/products", ProductController.getAll); +router.post("/product", ProductController.add); +router.get("/product/:product_id", ProductController.getById); +router.post("/product/:product_id/sku", ProductController.addSku); +router.patch("/product/:product_id/sku/:sku_id", ProductController.updateSku); +router.delete("/product/:product_id/sku/:sku_id", ProductController.deleteSku); +router.post( + "/product/:product_id/sku/:sku_id/default_price", + ProductController.setSkuDefaultPrice +); + +router.get("/orders", OrderController.getAll); +router.post("/order", OrderController.createOrder); +router.get("/order/:order_id", OrderController.get); +router.get("/order/:order_id/status", OrderController.getOrderStatus); + +router.get("/warehouse", WarehouseController.getAll); +router.get("/warehouse/:productId", WarehouseController.getProduct); +// router.get("/api/order/:id", OrderController.getOrderById); +// router.post("/api/order/:id/cancel", OrderController.cancelOrder); +// router.post("/api/order/:id/extend", OrderController.extendOrder); + +router.post("/payment/stripe", StripePaymentController.create); +router.post("/webhook/stripe", StripePaymentController.updatePayment); + +// router.get("/api/payment/vipps/token", VippsTokenController); +// router.get("/api/payment/:id/details", VippsPaymentController.getPaymentDetails); +// router.post( +// "/api/payment/callback/v2/payments/:id", +// VippsPaymentController.updatePayment +// ); + +router.get("/", (req, res) => res.send("hello")); + +app.use("/api", router); + +const server = createServer(app); +server.listen(port, () => logger.info(`Server started, listening at :${port}`)); + +setupCartWebsocketServer(server); diff --git a/src/webserver/websocketCartServer.ts b/src/webserver/websocketCartServer.ts new file mode 100644 index 0000000..7e8db53 --- /dev/null +++ b/src/webserver/websocketCartServer.ts @@ -0,0 +1,56 @@ +import cookie from "cookie"; +import { ClientRequest } from "http"; +import { WebSocketServer, Websocket, Request } from "ws"; +import WSCart from "../cart/WSCart"; +import CartSession from "../cart/CartSession"; +import generateUUID from "../utils/generateUUID"; + +function getCookieValue(cookieString: string, key: string): string | null { + const cookies = cookie.parse(cookieString || ""); + return cookies[key] || null; +} + +function getHeaderValue(url: string, key: string) { + const urlSegments = url.split("?"); + if (urlSegments?.length < 2) return; + + const query = new URLSearchParams(urlSegments[1]); + return query.get(key); +} + +function setupCartWebsocketServer(server) { + const WS_OPTIONS = { server }; + const wss = new WebSocketServer(WS_OPTIONS); + + const cartSession = new CartSession(); + // setInterval(() => cartSession.listCarts(), 3000); + setInterval(() => cartSession.removeIfNotAlive(), 1000); + + wss.on("connection", (ws, req) => { + const sessionId = generateUUID(); + let clientId = + getCookieValue(req.headers.cookie, "planetId") || + getHeaderValue(req.url, "planetId"); + + if (clientId === null) return; + + const wsCart = new WSCart(ws, clientId); + wsCart.cartSession = cartSession; + cartSession.add(sessionId, wsCart); + + ws.on("message", (data, isBinary) => wsCart.handleMessage(data, isBinary)); + + ws.on("close", () => { + cartSession.remove(sessionId); + console.log("the client has closed the connection"); + }); + + ws.onerror = function (error) { + console.log("unexpected ws error occured:", error); + }; + }); + + console.log("Booted websocket cart"); +} + +export { setupCartWebsocketServer };