diff --git a/src/api/server/.DS_Store b/src/api/server/.DS_Store new file mode 100644 index 0000000..5008ddf Binary files /dev/null and b/src/api/server/.DS_Store differ diff --git a/src/api/server/lib/dashboardWebSocket.js b/src/api/server/lib/dashboardWebSocket.js new file mode 100755 index 0000000..750704d --- /dev/null +++ b/src/api/server/lib/dashboardWebSocket.js @@ -0,0 +1,73 @@ +import WebSocket from 'ws'; +import url from 'url'; +import security from './security'; + +let wss = null; + +const listen = server => { + wss = new WebSocket.Server({ + path: '/ws/dashboard', //Accept only connections matching this path + maxPayload: 1024, //The maximum allowed message size + backlog: 100, //The maximum length of the queue of pending connections. + verifyClient: verifyClient, //An hook to reject connections + server //A pre-created HTTP/S server to use + }); + + wss.on('connection', onConnection); + wss.broadcast = broadcastToAll; +}; + +const getTokenFromRequestPath = requestPath => { + try { + const urlObj = url.parse(requestPath, true); + return urlObj.query.token; + } catch (e) { + return null; + } +}; + +const verifyClient = (info, done) => { + if (security.DEVELOPER_MODE === true) { + done(true); + } else { + const requestPath = info.req.url; + const token = getTokenFromRequestPath(requestPath); + security + .verifyToken(token) + .then(tokenDecoded => { + // TODO: check access to dashboard + done(true); + }) + .catch(err => { + done(false, 401); + }); + } +}; + +const onConnection = (ws, req) => { + // TODO: ws.user = token.email + ws.on('error', () => {}); +}; + +const broadcastToAll = data => { + wss.clients.forEach(client => { + if (client.readyState === WebSocket.OPEN) { + client.send(data, error => {}); + } + }); +}; + +const send = ({ event, payload }) => { + wss.broadcast(JSON.stringify({ event, payload })); +}; + +const events = { + ORDER_CREATED: 'order.created', + THEME_INSTALLED: 'theme.installed' +}; + +export default { + listen: listen, + send: send, + events: events +}; diff --git a/src/api/server/lib/logger.js b/src/api/server/lib/logger.js new file mode 100755 index 0000000..c2d9517 --- /dev/null +++ b/src/api/server/lib/logger.js @@ -0,0 +1,46 @@ +import winston from 'winston'; +const LOGS_FILE = 'logs/server.log'; + +winston.configure({ + transports: [ + new winston.transports.Console({ + level: 'debug', + handleExceptions: true, + format: winston.format.combine( + winston.format.colorize(), + winston.format.simple() + ) + }), + new winston.transports.File({ + level: 'info', + handleExceptions: true, + format: winston.format.json(), + filename: LOGS_FILE + }) + ] +}); + +const getResponse = message => ({ + error: true, + message +}); + +const logUnauthorizedRequests = req => { + // todo +}; + +const sendResponse = (err, req, res, next) => { + if (err && err.name === 'UnauthorizedError') { + logUnauthorizedRequests(req); + res.status(401).send(getResponse(err.message)); + } else if (err) { + winston.error(err.stack); + res.status(500).send(getResponse(err.message)); + } else { + next(); + } +}; + +export default { + sendResponse +}; diff --git a/src/api/server/lib/mailer.js b/src/api/server/lib/mailer.js new file mode 100755 index 0000000..db9a97c --- /dev/null +++ b/src/api/server/lib/mailer.js @@ -0,0 +1,80 @@ +import winston from 'winston'; +import nodemailer from 'nodemailer'; +import smtpTransport from 'nodemailer-smtp-transport'; +import settings from './settings'; +import EmailSettingsService from '../services/settings/email'; + +const SMTP_FROM_CONFIG_FILE = { + host: settings.smtpServer.host, + port: settings.smtpServer.port, + secure: settings.smtpServer.secure, + auth: { + user: settings.smtpServer.user, + pass: settings.smtpServer.pass + } +}; + +const getSmtpFromEmailSettings = emailSettings => { + return { + host: emailSettings.host, + port: emailSettings.port, + secure: emailSettings.port === 465, + auth: { + user: emailSettings.user, + pass: emailSettings.pass + } + }; +}; + +const getSmtp = emailSettings => { + const useSmtpServerFromConfigFile = emailSettings.host === ''; + const smtp = useSmtpServerFromConfigFile + ? SMTP_FROM_CONFIG_FILE + : getSmtpFromEmailSettings(emailSettings); + + return smtp; +}; + +const sendMail = (smtp, message) => { + return new Promise((resolve, reject) => { + if (!message.to.includes('@')) { + reject('Invalid email address'); + return; + } + + const transporter = nodemailer.createTransport(smtpTransport(smtp)); + transporter.sendMail(message, (err, info) => { + if (err) { + reject(err); + } else { + resolve(info); + } + }); + }); +}; + +const getFrom = emailSettings => { + const useSmtpServerFromConfigFile = emailSettings.host === ''; + return useSmtpServerFromConfigFile + ? `"${settings.smtpServer.fromName}" <${settings.smtpServer.fromAddress}>` + : `"${emailSettings.from_name}" <${emailSettings.from_address}>`; +}; + +const send = async message => { + const emailSettings = await EmailSettingsService.getEmailSettings(); + const smtp = getSmtp(emailSettings); + message.from = getFrom(emailSettings); + + try { + const result = await sendMail(smtp, message); + winston.info('Email sent', result); + return true; + } catch (e) { + winston.error('Email send failed', e); + return false; + } +}; + +export default { + send: send +}; diff --git a/src/api/server/lib/mongo.js b/src/api/server/lib/mongo.js new file mode 100755 index 0000000..a252943 --- /dev/null +++ b/src/api/server/lib/mongo.js @@ -0,0 +1,48 @@ +import winston from 'winston'; +import url from 'url'; +import { MongoClient } from 'mongodb'; +import settings from './settings'; + +const mongodbConnection = settings.mongodbServerUrl; +const mongoPathName = url.parse(mongodbConnection).pathname; +const dbName = mongoPathName.substring(mongoPathName.lastIndexOf('/') + 1); + +const RECONNECT_INTERVAL = 1000; +const CONNECT_OPTIONS = { + reconnectTries: 3600, + reconnectInterval: RECONNECT_INTERVAL, + useNewUrlParser: true +}; + +const onClose = () => { + winston.info('MongoDB connection was closed'); +}; + +const onReconnect = () => { + winston.info('MongoDB reconnected'); +}; + +export let db = null; + +const connectWithRetry = () => { + MongoClient.connect( + mongodbConnection, + CONNECT_OPTIONS, + (err, client) => { + if (err) { + winston.error( + `MongoDB connection was failed: ${err.message}`, + err.message + ); + setTimeout(connectWithRetry, RECONNECT_INTERVAL); + } else { + db = client.db(dbName); + db.on('close', onClose); + db.on('reconnect', onReconnect); + winston.info('MongoDB connected successfully'); + } + } + ); +}; + +connectWithRetry(); diff --git a/src/api/server/lib/parse.js b/src/api/server/lib/parse.js new file mode 100755 index 0000000..1141473 --- /dev/null +++ b/src/api/server/lib/parse.js @@ -0,0 +1,147 @@ +import { ObjectID } from 'mongodb'; + +const getString = value => (value || '').toString(); + +const getDateIfValid = value => { + const date = Date.parse(value); + return isNaN(date) ? null : new Date(date); +}; + +const getArrayIfValid = value => { + return Array.isArray(value) ? value : null; +}; + +const getArrayOfObjectID = value => { + if (Array.isArray(value) && value.length > 0) { + return value.map(id => getObjectIDIfValid(id)).filter(id => !!id); + } else { + return []; + } +}; + +const isNumber = value => !isNaN(parseFloat(value)) && isFinite(value); + +const getNumberIfValid = value => (isNumber(value) ? parseFloat(value) : null); + +const getNumberIfPositive = value => { + const n = getNumberIfValid(value); + return n && n >= 0 ? n : null; +}; + +const getBooleanIfValid = (value, defaultValue = null) => { + if (value === 'true' || value === 'false') { + return value === 'true'; + } else { + return typeof value === 'boolean' ? value : defaultValue; + } +}; + +const getObjectIDIfValid = value => { + return ObjectID.isValid(value) ? new ObjectID(value) : null; +}; + +const getBrowser = browser => { + return browser + ? { + ip: getString(browser.ip), + user_agent: getString(browser.user_agent) + } + : { + ip: '', + user_agent: '' + }; +}; + +const getCustomerAddress = address => { + let coordinates = { + latitude: '', + longitude: '' + }; + + if (address && address.coordinates) { + coordinates.latitude = address.coordinates.latitude; + coordinates.longitude = address.coordinates.longitude; + } + + return address + ? { + id: new ObjectID(), + address1: getString(address.address1), + address2: getString(address.address2), + city: getString(address.city), + country: getString(address.country).toUpperCase(), + state: getString(address.state), + phone: getString(address.phone), + postal_code: getString(address.postal_code), + full_name: getString(address.full_name), + company: getString(address.company), + tax_number: getString(address.tax_number), + coordinates: coordinates, + details: address.details, + default_billing: false, + default_shipping: false + } + : {}; +}; + +const getOrderAddress = address => { + let coordinates = { + latitude: '', + longitude: '' + }; + + if (address && address.coordinates) { + coordinates.latitude = address.coordinates.latitude; + coordinates.longitude = address.coordinates.longitude; + } + + const emptyAddress = { + address1: '', + address2: '', + city: '', + country: '', + state: '', + phone: '', + postal_code: '', + full_name: '', + company: '', + tax_number: '', + coordinates: coordinates, + details: null + }; + + return address + ? Object.assign( + {}, + { + address1: getString(address.address1), + address2: getString(address.address2), + city: getString(address.city), + country: getString(address.country).toUpperCase(), + state: getString(address.state), + phone: getString(address.phone), + postal_code: getString(address.postal_code), + full_name: getString(address.full_name), + company: getString(address.company), + tax_number: getString(address.tax_number), + coordinates: coordinates, + details: address.details + }, + address + ) + : emptyAddress; +}; + +export default { + getString: getString, + getObjectIDIfValid: getObjectIDIfValid, + getDateIfValid: getDateIfValid, + getArrayIfValid: getArrayIfValid, + getArrayOfObjectID: getArrayOfObjectID, + getNumberIfValid: getNumberIfValid, + getNumberIfPositive: getNumberIfPositive, + getBooleanIfValid: getBooleanIfValid, + getBrowser: getBrowser, + getCustomerAddress: getCustomerAddress, + getOrderAddress: getOrderAddress +}; diff --git a/src/api/server/lib/security.js b/src/api/server/lib/security.js new file mode 100755 index 0000000..ab1dbfd --- /dev/null +++ b/src/api/server/lib/security.js @@ -0,0 +1,107 @@ +import jwt from 'jsonwebtoken'; +import expressJwt from 'express-jwt'; +import settings from './settings'; +import SecurityTokensService from '../services/security/tokens'; + +const DEVELOPER_MODE = settings.developerMode === true; +const SET_TOKEN_AS_REVOKEN_ON_EXCEPTION = true; + +const PATHS_WITH_OPEN_ACCESS = [ + '/api/v1/authorize', + /\/api\/v1\/notifications/i, + /\/ajax\//i +]; + +const scope = { + ADMIN: 'admin', + DASHBOARD: 'dashboard', + READ_PRODUCTS: 'read:products', + WRITE_PRODUCTS: 'write:products', + READ_PRODUCT_CATEGORIES: 'read:product_categories', + WRITE_PRODUCT_CATEGORIES: 'write:product_categories', + READ_ORDERS: 'read:orders', + WRITE_ORDERS: 'write:orders', + READ_CUSTOMERS: 'read:customers', + WRITE_CUSTOMERS: 'write:customers', + READ_CUSTOMER_GROUPS: 'read:customer_groups', + WRITE_CUSTOMER_GROUPS: 'write:customer_groups', + READ_PAGES: 'read:pages', + WRITE_PAGES: 'write:pages', + READ_ORDER_STATUSES: 'read:order_statuses', + WRITE_ORDER_STATUSES: 'write:order_statuses', + READ_THEME: 'read:theme', + WRITE_THEME: 'write:theme', + READ_SITEMAP: 'read:sitemap', + READ_SHIPPING_METHODS: 'read:shipping_methods', + WRITE_SHIPPING_METHODS: 'write:shipping_methods', + READ_PAYMENT_METHODS: 'read:payment_methods', + WRITE_PAYMENT_METHODS: 'write:payment_methods', + READ_SETTINGS: 'read:settings', + WRITE_SETTINGS: 'write:settings', + READ_FILES: 'read:files', + WRITE_FILES: 'write:files' +}; + +const checkUserScope = (requiredScope, req, res, next) => { + if (DEVELOPER_MODE === true) { + next(); + } else if ( + req.user && + req.user.scopes && + req.user.scopes.length > 0 && + (req.user.scopes.includes(scope.ADMIN) || + req.user.scopes.includes(requiredScope)) + ) { + next(); + } else { + res.status(403).send({ error: true, message: 'Forbidden' }); + } +}; + +const verifyToken = token => { + return new Promise((resolve, reject) => { + jwt.verify(token, settings.jwtSecretKey, (err, decoded) => { + if (err) { + reject(err); + } else { + // check on blacklist + resolve(decoded); + } + }); + }); +}; + +const checkTokenInBlacklistCallback = async (req, payload, done) => { + try { + const jti = payload.jti; + const blacklist = await SecurityTokensService.getTokensBlacklist(); + const tokenIsRevoked = blacklist.includes(jti); + return done(null, tokenIsRevoked); + } catch (e) { + done(e, SET_TOKEN_AS_REVOKEN_ON_EXCEPTION); + } +}; + +const applyMiddleware = app => { + if (DEVELOPER_MODE === false) { + app.use( + expressJwt({ + secret: settings.jwtSecretKey, + isRevoked: checkTokenInBlacklistCallback + }).unless({ path: PATHS_WITH_OPEN_ACCESS }) + ); + } +}; + +const getAccessControlAllowOrigin = () => { + return settings.storeBaseUrl || '*'; +}; + +export default { + checkUserScope: checkUserScope, + scope: scope, + verifyToken: verifyToken, + applyMiddleware: applyMiddleware, + getAccessControlAllowOrigin: getAccessControlAllowOrigin, + DEVELOPER_MODE: DEVELOPER_MODE +}; diff --git a/src/api/server/lib/settings.js b/src/api/server/lib/settings.js new file mode 100755 index 0000000..8e6d7de --- /dev/null +++ b/src/api/server/lib/settings.js @@ -0,0 +1 @@ +export { default } from '../../../../config/server'; diff --git a/src/api/server/lib/utils.js b/src/api/server/lib/utils.js new file mode 100755 index 0000000..616a69a --- /dev/null +++ b/src/api/server/lib/utils.js @@ -0,0 +1,52 @@ +import slug from 'slug'; +import SitemapService from '../services/sitemap'; + +const slugConfig = { + symbols: false, // replace unicode symbols or not + remove: null, // (optional) regex to remove characters + lower: true // result in lower case +}; + +const cleanSlug = text => { + return slug(text || '', slugConfig); +}; + +const getAvailableSlug = (path, resource, enableCleanPath = true) => { + return SitemapService.getPaths().then(paths => { + if (enableCleanPath) { + path = cleanSlug(path); + } + + let pathExists = paths.find( + e => e.path === '/' + path && e.resource != resource + ); + while (pathExists) { + path += '-2'; + pathExists = paths.find( + e => e.path === '/' + path && e.resource != resource + ); + } + return path; + }); +}; + +const getCorrectFileName = filename => { + if (filename) { + // replace unsafe characters + return filename.replace(/[\s*/:;&?@$()<>#%\{\}|\\\^\~\[\]]/g, '-'); + } else { + return filename; + } +}; + +const getProjectionFromFields = fields => { + const fieldsArray = fields && fields.length > 0 ? fields.split(',') : []; + return Object.assign({}, ...fieldsArray.map(key => ({ [key]: 1 }))); +}; + +export default { + cleanSlug: cleanSlug, + getAvailableSlug: getAvailableSlug, + getCorrectFileName: getCorrectFileName, + getProjectionFromFields: getProjectionFromFields +}; diff --git a/src/api/server/lib/webhooks.js b/src/api/server/lib/webhooks.js new file mode 100755 index 0000000..422c626 --- /dev/null +++ b/src/api/server/lib/webhooks.js @@ -0,0 +1,64 @@ +import crypto from 'crypto'; +import fetch from 'node-fetch'; +import WebhooksService from '../services/webhooks'; + +const trigger = async ({ event, payload }) => { + const webhooks = await WebhooksService.getWebhooks(); + for (const webhook of webhooks) { + if (webhook.events.includes(event)) { + send({ event, payload, webhook }); + } + } +}; + +const send = ({ event, payload, webhook }) => { + if ( + webhook && + webhook.enabled === true && + webhook.url && + webhook.url.length > 0 + ) { + const data = JSON.stringify(payload); + const signature = sign({ data: data, secret: webhook.secret }); + + fetch(webhook.url, { + method: 'POST', + body: data, + redirect: 'manual', + compress: true, + headers: { + 'Content-Type': 'application/json', + 'X-Hook-Event': event, + 'X-Hook-Signature': signature + } + }).catch(() => {}); + } +}; + +const sign = ({ data, secret }) => { + if (secret && secret.length > 0) { + const hmac = crypto.createHmac('sha256', secret); + hmac.update(data); + const signature = hmac.digest('hex'); + return signature; + } else { + return ''; + } +}; + +const events = { + ORDER_CREATED: 'order.created', + ORDER_UPDATED: 'order.updated', + ORDER_DELETED: 'order.deleted', + TRANSACTION_CREATED: 'transaction.created', + TRANSACTION_UPDATED: 'transaction.updated', + TRANSACTION_DELETED: 'transaction.deleted', + CUSTOMER_CREATED: 'customer.created', + CUSTOMER_UPDATED: 'customer.updated', + CUSTOMER_DELETED: 'customer.deleted' +}; + +export default { + trigger: trigger, + events: events +}; diff --git a/src/api/server/paymentGateways/LiqPay.js b/src/api/server/paymentGateways/LiqPay.js new file mode 100755 index 0000000..12ee3fd --- /dev/null +++ b/src/api/server/paymentGateways/LiqPay.js @@ -0,0 +1,94 @@ +import crypto from 'crypto'; +import OrdersService from '../services/orders/orders'; +import OrdertTansactionsService from '../services/orders/orderTransactions'; + +const getPaymentFormSettings = options => { + const { gateway, gatewaySettings, order, amount, currency } = options; + const params = { + sandbox: '0', + action: 'pay', + version: '3', + amount: amount, + currency: currency, + description: 'Order: ' + order.number, + order_id: order.id, + public_key: gatewaySettings.public_key, + language: gatewaySettings.language, + server_url: gatewaySettings.server_url + }; + + const form = getForm(params, gatewaySettings.private_key); + + const formSettings = { + data: form.data, + signature: form.signature, + language: gatewaySettings.language + }; + + return Promise.resolve(formSettings); +}; + +const paymentNotification = options => { + const { gateway, gatewaySettings, req, res } = options; + const params = req.body; + const dataStr = Buffer.from(params.data, 'base64').toString(); + const data = JSON.parse(dataStr); + + res.status(200).end(); + + const sign = getHashFromString( + gatewaySettings.private_key + params.data + gatewaySettings.private_key + ); + const signatureValid = sign === params.signature; + const paymentSuccess = data.status === 'success'; + const orderId = data.order_id; + + if (signatureValid && paymentSuccess) { + OrdersService.updateOrder(orderId, { + paid: true, + date_paid: new Date() + }).then(() => { + OrdertTansactionsService.addTransaction(orderId, { + transaction_id: data.transaction_id, + amount: data.amount, + currency: data.currency, + status: data.status, + details: `${data.paytype}, ${data.sender_card_mask2}`, + success: true + }); + }); + } else { + // log + } +}; + +const getForm = (params, private_key) => { + params = getFormParams(params); + let data = new Buffer(JSON.stringify(params)).toString('base64'); + let signature = getHashFromString(private_key + data + private_key); + + return { + data: data, + signature: signature + }; +}; + +const getFormParams = params => { + if (!params.version) throw new Error('version is null'); + if (!params.amount) throw new Error('amount is null'); + if (!params.currency) throw new Error('currency is null'); + if (!params.description) throw new Error('description is null'); + + return params; +}; + +const getHashFromString = str => { + let sha1 = crypto.createHash('sha1'); + sha1.update(str); + return sha1.digest('base64'); +}; + +export default { + getPaymentFormSettings: getPaymentFormSettings, + paymentNotification: paymentNotification +}; diff --git a/src/api/server/paymentGateways/PayPalCheckout.js b/src/api/server/paymentGateways/PayPalCheckout.js new file mode 100755 index 0000000..d446f2c --- /dev/null +++ b/src/api/server/paymentGateways/PayPalCheckout.js @@ -0,0 +1,123 @@ +import https from 'https'; +import qs from 'query-string'; +import OrdersService from '../services/orders/orders'; +import OrdertTansactionsService from '../services/orders/orderTransactions'; + +const SANDBOX_URL = 'www.sandbox.paypal.com'; +const REGULAR_URL = 'www.paypal.com'; + +const getPaymentFormSettings = options => { + const { gateway, gatewaySettings, order, amount, currency } = options; + + const formSettings = { + order_id: order.id, + amount: amount, + currency: currency, + env: gatewaySettings.env, + client: gatewaySettings.client, + size: gatewaySettings.size, + shape: gatewaySettings.shape, + color: gatewaySettings.color, + notify_url: gatewaySettings.notify_url + }; + + return Promise.resolve(formSettings); +}; + +const paymentNotification = options => { + const { gateway, gatewaySettings, req, res } = options; + const settings = { allow_sandbox: true }; + const params = req.body; + const orderId = params.custom; + const paymentCompleted = params.payment_status === 'Completed'; + + res.status(200).end(); + + verify(params, settings) + .then(() => { + // TODO: Validate that the receiver's email address is registered to you. + // TODO: Verify that the price, item description, and so on, match the transaction on your website. + + if (paymentCompleted) { + OrdersService.updateOrder(orderId, { + paid: true, + date_paid: new Date() + }).then(() => { + OrdertTansactionsService.addTransaction(orderId, { + transaction_id: params.txn_id, + amount: params.mc_gross, + currency: params.mc_currency, + status: params.payment_status, + details: `${params.first_name} ${params.last_name}, ${ + params.payer_email + }`, + success: true + }); + }); + } + }) + .catch(e => { + console.error(e); + }); +}; + +const verify = (params, settings) => { + return new Promise((resolve, reject) => { + if (!settings) { + settings = { + allow_sandbox: false + }; + } + + if (!params || Object.keys(params).length === 0) { + return reject('Params is empty'); + } + + params.cmd = '_notify-validate'; + const body = qs.stringify(params); + + //Set up the request to paypal + let req_options = { + host: params.test_ipn ? SANDBOX_URL : REGULAR_URL, + method: 'POST', + path: '/cgi-bin/webscr', + headers: { 'Content-Length': body.length } + }; + + if (params.test_ipn && !settings.allow_sandbox) { + return reject( + 'Received request with test_ipn parameter while sandbox is disabled' + ); + } + + let req = https.request(req_options, res => { + let data = []; + + res.on('data', d => { + data.push(d); + }); + + res.on('end', () => { + let response = data.join(''); + + //Check if IPN is valid + if (response === 'VERIFIED') { + return resolve(response); + } else { + return reject('IPN Verification status: ' + response); + } + }); + }); + + //Add the post parameters to the request body + req.write(body); + //Request error + req.on('error', reject); + req.end(); + }); +}; + +export default { + getPaymentFormSettings: getPaymentFormSettings, + paymentNotification: paymentNotification +}; diff --git a/src/api/server/paymentGateways/StripeElements.js b/src/api/server/paymentGateways/StripeElements.js new file mode 100755 index 0000000..1bf5d87 --- /dev/null +++ b/src/api/server/paymentGateways/StripeElements.js @@ -0,0 +1,61 @@ +import stripePackage from 'stripe'; +import OrdersService from '../services/orders/orders'; +import OrdertTansactionsService from '../services/orders/orderTransactions'; + +const getPaymentFormSettings = options => { + const { gateway, gatewaySettings, order, amount, currency } = options; + const formSettings = { + order_id: order.id, + amount, + currency, + email: order.email, + public_key: gatewaySettings.public_key + }; + return Promise.resolve(formSettings); +}; + +const processOrderPayment = async ({ order, gatewaySettings, settings }) => { + try { + const stripe = stripePackage(gatewaySettings.secret_key); + const charge = await stripe.charges.create({ + amount: order.grand_total * 100, + currency: settings.currency_code, + description: `Order #${order.number}`, + statement_descriptor: `Order #${order.number}`, + metadata: { + order_id: order.id + }, + source: order.payment_token + }); + + // status: succeeded, pending, failed + const paymentSucceeded = + charge.status === 'succeeded' || charge.paid === true; + + if (paymentSucceeded) { + await OrdersService.updateOrder(order.id, { + paid: true, + date_paid: new Date() + }); + } + + await OrdertTansactionsService.addTransaction(order.id, { + transaction_id: charge.id, + amount: charge.amount / 100, + currency: charge.currency, + status: charge.status, + details: charge.outcome.seller_message, + success: paymentSucceeded + }); + + return paymentSucceeded; + } catch (err) { + // handle errors + return false; + } +}; + +export default { + getPaymentFormSettings, + processOrderPayment +}; diff --git a/src/api/server/paymentGateways/index.js b/src/api/server/paymentGateways/index.js new file mode 100755 index 0000000..1192c0e --- /dev/null +++ b/src/api/server/paymentGateways/index.js @@ -0,0 +1,92 @@ +import OrdersService from '../services/orders/orders'; +import SettingsService from '../services/settings/settings'; +import PaymentGatewaysService from '../services/settings/paymentGateways'; +import PayPalCheckout from './PayPalCheckout'; +import LiqPay from './LiqPay'; +import StripeElements from './StripeElements'; + +const getOptions = orderId => { + return Promise.all([ + OrdersService.getSingleOrder(orderId), + SettingsService.getSettings() + ]).then(([order, settings]) => { + if (order && order.payment_method_id) { + return PaymentGatewaysService.getGateway( + order.payment_method_gateway + ).then(gatewaySettings => { + const options = { + gateway: order.payment_method_gateway, + gatewaySettings: gatewaySettings, + order: order, + amount: order.grand_total, + currency: settings.currency_code + }; + + return options; + }); + } + }); +}; + +const getPaymentFormSettings = orderId => { + return getOptions(orderId).then(options => { + switch (options.gateway) { + case 'paypal-checkout': + return PayPalCheckout.getPaymentFormSettings(options); + case 'liqpay': + return LiqPay.getPaymentFormSettings(options); + case 'stripe-elements': + return StripeElements.getPaymentFormSettings(options); + default: + return Promise.reject('Invalid gateway'); + } + }); +}; + +const paymentNotification = (req, res, gateway) => { + return PaymentGatewaysService.getGateway(gateway).then(gatewaySettings => { + const options = { + gateway: gateway, + gatewaySettings: gatewaySettings, + req: req, + res: res + }; + + switch (gateway) { + case 'paypal-checkout': + return PayPalCheckout.paymentNotification(options); + case 'liqpay': + return LiqPay.paymentNotification(options); + default: + return Promise.reject('Invalid gateway'); + } + }); +}; + +const processOrderPayment = async order => { + const orderAlreadyCharged = order.paid === true; + if (orderAlreadyCharged) { + return true; + } + + const gateway = order.payment_method_gateway; + const gatewaySettings = await PaymentGatewaysService.getGateway(gateway); + const settings = await SettingsService.getSettings(); + + switch (gateway) { + case 'stripe-elements': + return StripeElements.processOrderPayment({ + order, + gatewaySettings, + settings + }); + default: + return Promise.reject('Invalid gateway'); + } +}; + +export default { + getPaymentFormSettings: getPaymentFormSettings, + paymentNotification: paymentNotification, + processOrderPayment: processOrderPayment +}; diff --git a/src/api/server/routes/apps.js b/src/api/server/routes/apps.js new file mode 100755 index 0000000..c53b3e0 --- /dev/null +++ b/src/api/server/routes/apps.js @@ -0,0 +1,44 @@ +import security from '../lib/security'; +import AppSettingsService from '../services/apps/settings'; + +class AppsRoute { + constructor(router) { + this.router = router; + this.registerRoutes(); + } + + registerRoutes() { + this.router.get( + '/v1/apps/:key/settings', + security.checkUserScope.bind(this, security.scope.READ_SETTINGS), + this.getSettings.bind(this) + ); + this.router.put( + '/v1/apps/:key/settings', + security.checkUserScope.bind(this, security.scope.WRITE_SETTINGS), + this.updateSettings.bind(this) + ); + } + + getSettings(req, res, next) { + AppSettingsService.getSettings(req.params.key) + .then(data => { + res.send(data); + }) + .catch(next); + } + + updateSettings(req, res, next) { + AppSettingsService.updateSettings(req.params.key, req.body) + .then(data => { + if (data) { + res.send(data); + } else { + res.status(404).end(); + } + }) + .catch(next); + } +} + +export default AppsRoute; diff --git a/src/api/server/routes/customerGroups.js b/src/api/server/routes/customerGroups.js new file mode 100755 index 0000000..3f539ef --- /dev/null +++ b/src/api/server/routes/customerGroups.js @@ -0,0 +1,87 @@ +import security from '../lib/security'; +import CustomerGroupsService from '../services/customers/customerGroups'; + +class CustomerGroupsRoute { + constructor(router) { + this.router = router; + this.registerRoutes(); + } + + registerRoutes() { + this.router.get( + '/v1/customer_groups', + security.checkUserScope.bind(this, security.scope.READ_CUSTOMER_GROUPS), + this.getGroups.bind(this) + ); + this.router.post( + '/v1/customer_groups', + security.checkUserScope.bind(this, security.scope.WRITE_CUSTOMER_GROUPS), + this.addGroup.bind(this) + ); + this.router.get( + '/v1/customer_groups/:id', + security.checkUserScope.bind(this, security.scope.READ_CUSTOMER_GROUPS), + this.getSingleGroup.bind(this) + ); + this.router.put( + '/v1/customer_groups/:id', + security.checkUserScope.bind(this, security.scope.WRITE_CUSTOMER_GROUPS), + this.updateGroup.bind(this) + ); + this.router.delete( + '/v1/customer_groups/:id', + security.checkUserScope.bind(this, security.scope.WRITE_CUSTOMER_GROUPS), + this.deleteGroup.bind(this) + ); + } + + getGroups(req, res, next) { + CustomerGroupsService.getGroups(req.query) + .then(data => { + res.send(data); + }) + .catch(next); + } + + getSingleGroup(req, res, next) { + CustomerGroupsService.getSingleGroup(req.params.id) + .then(data => { + if (data) { + res.send(data); + } else { + res.status(404).end(); + } + }) + .catch(next); + } + + addGroup(req, res, next) { + CustomerGroupsService.addGroup(req.body) + .then(data => { + res.send(data); + }) + .catch(next); + } + + updateGroup(req, res, next) { + CustomerGroupsService.updateGroup(req.params.id, req.body) + .then(data => { + if (data) { + res.send(data); + } else { + res.status(404).end(); + } + }) + .catch(next); + } + + deleteGroup(req, res, next) { + CustomerGroupsService.deleteGroup(req.params.id) + .then(data => { + res.status(data ? 200 : 404).end(); + }) + .catch(next); + } +} + +export default CustomerGroupsRoute; diff --git a/src/api/server/routes/customers.js b/src/api/server/routes/customers.js new file mode 100755 index 0000000..3b6f98b --- /dev/null +++ b/src/api/server/routes/customers.js @@ -0,0 +1,161 @@ +import security from '../lib/security'; +import CustomersService from '../services/customers/customers'; + +class CustomersRoute { + constructor(router) { + this.router = router; + this.registerRoutes(); + } + + registerRoutes() { + this.router.get( + '/v1/customers', + security.checkUserScope.bind(this, security.scope.READ_CUSTOMERS), + this.getCustomers.bind(this) + ); + this.router.post( + '/v1/customers', + security.checkUserScope.bind(this, security.scope.WRITE_CUSTOMERS), + this.addCustomer.bind(this) + ); + this.router.get( + '/v1/customers/:id', + security.checkUserScope.bind(this, security.scope.READ_CUSTOMERS), + this.getSingleCustomer.bind(this) + ); + this.router.put( + '/v1/customers/:id', + security.checkUserScope.bind(this, security.scope.WRITE_CUSTOMERS), + this.updateCustomer.bind(this) + ); + this.router.delete( + '/v1/customers/:id', + security.checkUserScope.bind(this, security.scope.WRITE_CUSTOMERS), + this.deleteCustomer.bind(this) + ); + this.router.post( + '/v1/customers/:id/addresses', + security.checkUserScope.bind(this, security.scope.WRITE_CUSTOMERS), + this.addAddress.bind(this) + ); + this.router.put( + '/v1/customers/:id/addresses/:address_id', + security.checkUserScope.bind(this, security.scope.WRITE_CUSTOMERS), + this.updateAddress.bind(this) + ); + this.router.delete( + '/v1/customers/:id/addresses/:address_id', + security.checkUserScope.bind(this, security.scope.WRITE_CUSTOMERS), + this.deleteAddress.bind(this) + ); + this.router.post( + '/v1/customers/:id/addresses/:address_id/default_billing', + security.checkUserScope.bind(this, security.scope.WRITE_CUSTOMERS), + this.setDefaultBilling.bind(this) + ); + this.router.post( + '/v1/customers/:id/addresses/:address_id/default_shipping', + security.checkUserScope.bind(this, security.scope.WRITE_CUSTOMERS), + this.setDefaultShipping.bind(this) + ); + } + + getCustomers(req, res, next) { + CustomersService.getCustomers(req.query) + .then(data => { + res.send(data); + }) + .catch(next); + } + + getSingleCustomer(req, res, next) { + CustomersService.getSingleCustomer(req.params.id) + .then(data => { + if (data) { + res.send(data); + } else { + res.status(404).end(); + } + }) + .catch(next); + } + + addCustomer(req, res, next) { + CustomersService.addCustomer(req.body) + .then(data => { + res.send(data); + }) + .catch(next); + } + + updateCustomer(req, res, next) { + CustomersService.updateCustomer(req.params.id, req.body) + .then(data => { + if (data) { + res.send(data); + } else { + res.status(404).end(); + } + }) + .catch(next); + } + + deleteCustomer(req, res, next) { + CustomersService.deleteCustomer(req.params.id) + .then(data => { + res.status(data ? 200 : 404).end(); + }) + .catch(next); + } + + addAddress(req, res, next) { + const customer_id = req.params.id; + CustomersService.addAddress(customer_id, req.body) + .then(data => { + res.end(); + }) + .catch(next); + } + + updateAddress(req, res, next) { + const customer_id = req.params.id; + const address_id = req.params.address_id; + CustomersService.updateAddress(customer_id, address_id, req.body) + .then(data => { + res.end(); + }) + .catch(next); + } + + deleteAddress(req, res, next) { + const customer_id = req.params.id; + const address_id = req.params.address_id; + CustomersService.deleteAddress(customer_id, address_id) + .then(data => { + res.end(); + }) + .catch(next); + } + + setDefaultBilling(req, res, next) { + const customer_id = req.params.id; + const address_id = req.params.address_id; + CustomersService.setDefaultBilling(customer_id, address_id) + .then(data => { + res.end(); + }) + .catch(next); + } + + setDefaultShipping(req, res, next) { + const customer_id = req.params.id; + const address_id = req.params.address_id; + CustomersService.setDefaultShipping(customer_id, address_id) + .then(data => { + res.end(); + }) + .catch(next); + } +} + +export default CustomersRoute; diff --git a/src/api/server/routes/files.js b/src/api/server/routes/files.js new file mode 100755 index 0000000..5b6cd42 --- /dev/null +++ b/src/api/server/routes/files.js @@ -0,0 +1,49 @@ +import security from '../lib/security'; +import FilesService from '../services/files'; + +class FilesRoute { + constructor(router) { + this.router = router; + this.registerRoutes(); + } + + registerRoutes() { + this.router.get( + '/v1/files', + security.checkUserScope.bind(this, security.scope.READ_FILES), + this.getFiles.bind(this) + ); + this.router.post( + '/v1/files', + security.checkUserScope.bind(this, security.scope.WRITE_FILES), + this.uploadFile.bind(this) + ); + this.router.delete( + '/v1/files/:file', + security.checkUserScope.bind(this, security.scope.WRITE_FILES), + this.deleteFile.bind(this) + ); + } + + getFiles(req, res, next) { + FilesService.getFiles() + .then(data => { + res.send(data); + }) + .catch(next); + } + + uploadFile(req, res, next) { + FilesService.uploadFile(req, res, next); + } + + deleteFile(req, res, next) { + FilesService.deleteFile(req.params.file) + .then(() => { + res.end(); + }) + .catch(next); + } +} + +export default FilesRoute; diff --git a/src/api/server/routes/notifications.js b/src/api/server/routes/notifications.js new file mode 100755 index 0000000..bd72c71 --- /dev/null +++ b/src/api/server/routes/notifications.js @@ -0,0 +1,21 @@ +import PaymentGateways from '../paymentGateways'; + +class NotificationsRoute { + constructor(router) { + this.router = router; + this.registerRoutes(); + } + + registerRoutes() { + this.router.post( + '/v1/notifications/:gateway', + this.paymentNotification.bind(this) + ); + } + + paymentNotification(req, res, next) { + PaymentGateways.paymentNotification(req, res, req.params.gateway); + } +} + +export default NotificationsRoute; diff --git a/src/api/server/routes/orderStatuses.js b/src/api/server/routes/orderStatuses.js new file mode 100755 index 0000000..dacb5c6 --- /dev/null +++ b/src/api/server/routes/orderStatuses.js @@ -0,0 +1,87 @@ +import security from '../lib/security'; +import OrderStatusesService from '../services/orders/orderStatuses'; + +class OrderStatusesRoute { + constructor(router) { + this.router = router; + this.registerRoutes(); + } + + registerRoutes() { + this.router.get( + '/v1/order_statuses', + security.checkUserScope.bind(this, security.scope.READ_ORDER_STATUSES), + this.getStatuses.bind(this) + ); + this.router.post( + '/v1/order_statuses', + security.checkUserScope.bind(this, security.scope.WRITE_ORDER_STATUSES), + this.addStatus.bind(this) + ); + this.router.get( + '/v1/order_statuses/:id', + security.checkUserScope.bind(this, security.scope.READ_ORDER_STATUSES), + this.getSingleStatus.bind(this) + ); + this.router.put( + '/v1/order_statuses/:id', + security.checkUserScope.bind(this, security.scope.WRITE_ORDER_STATUSES), + this.updateStatus.bind(this) + ); + this.router.delete( + '/v1/order_statuses/:id', + security.checkUserScope.bind(this, security.scope.WRITE_ORDER_STATUSES), + this.deleteStatus.bind(this) + ); + } + + getStatuses(req, res, next) { + OrderStatusesService.getStatuses(req.query) + .then(data => { + res.send(data); + }) + .catch(next); + } + + getSingleStatus(req, res, next) { + OrderStatusesService.getSingleStatus(req.params.id) + .then(data => { + if (data) { + res.send(data); + } else { + res.status(404).end(); + } + }) + .catch(next); + } + + addStatus(req, res, next) { + OrderStatusesService.addStatus(req.body) + .then(data => { + res.send(data); + }) + .catch(next); + } + + updateStatus(req, res, next) { + OrderStatusesService.updateStatus(req.params.id, req.body) + .then(data => { + if (data) { + res.send(data); + } else { + res.status(404).end(); + } + }) + .catch(next); + } + + deleteStatus(req, res, next) { + OrderStatusesService.deleteStatus(req.params.id) + .then(data => { + res.status(data ? 200 : 404).end(); + }) + .catch(next); + } +} + +export default OrderStatusesRoute; diff --git a/src/api/server/routes/orders.js b/src/api/server/routes/orders.js new file mode 100755 index 0000000..afea5fe --- /dev/null +++ b/src/api/server/routes/orders.js @@ -0,0 +1,367 @@ +import security from '../lib/security'; +import OrdersService from '../services/orders/orders'; +import OrderAddressService from '../services/orders/orderAddress'; +import OrderItemsService from '../services/orders/orderItems'; +import OrdertTansactionsService from '../services/orders/orderTransactions'; +import OrdertDiscountsService from '../services/orders/orderDiscounts'; +import SettingsService from '../services/settings/settings'; +import PaymentGateways from '../paymentGateways'; + +class OrdersRoute { + constructor(router) { + this.router = router; + this.registerRoutes(); + } + + registerRoutes() { + this.router.get( + '/v1/orders', + security.checkUserScope.bind(this, security.scope.READ_ORDERS), + this.getOrders.bind(this) + ); + this.router.post( + '/v1/orders', + security.checkUserScope.bind(this, security.scope.WRITE_ORDERS), + this.addOrder.bind(this) + ); + this.router.get( + '/v1/orders/:id', + security.checkUserScope.bind(this, security.scope.READ_ORDERS), + this.getSingleOrder.bind(this) + ); + this.router.put( + '/v1/orders/:id', + security.checkUserScope.bind(this, security.scope.WRITE_ORDERS), + this.updateOrder.bind(this) + ); + this.router.delete( + '/v1/orders/:id', + security.checkUserScope.bind(this, security.scope.WRITE_ORDERS), + this.deleteOrder.bind(this) + ); + + this.router.put( + '/v1/orders/:id/recalculate', + security.checkUserScope.bind(this, security.scope.WRITE_ORDERS), + this.recalculateOrder.bind(this) + ); + this.router.put( + '/v1/orders/:id/checkout', + security.checkUserScope.bind(this, security.scope.WRITE_ORDERS), + this.checkoutOrder.bind(this) + ); + this.router.put( + '/v1/orders/:id/cancel', + security.checkUserScope.bind(this, security.scope.WRITE_ORDERS), + this.cancelOrder.bind(this) + ); + this.router.put( + '/v1/orders/:id/close', + security.checkUserScope.bind(this, security.scope.WRITE_ORDERS), + this.closeOrder.bind(this) + ); + + this.router.put( + '/v1/orders/:id/billing_address', + security.checkUserScope.bind(this, security.scope.WRITE_ORDERS), + this.updateBillingAddress.bind(this) + ); + this.router.put( + '/v1/orders/:id/shipping_address', + security.checkUserScope.bind(this, security.scope.WRITE_ORDERS), + this.updateShippingAddress.bind(this) + ); + + this.router.post( + '/v1/orders/:id/items', + security.checkUserScope.bind(this, security.scope.WRITE_ORDERS), + this.addItem.bind(this) + ); + this.router.put( + '/v1/orders/:id/items/:item_id', + security.checkUserScope.bind(this, security.scope.WRITE_ORDERS), + this.updateItem.bind(this) + ); + this.router.delete( + '/v1/orders/:id/items/:item_id', + security.checkUserScope.bind(this, security.scope.WRITE_ORDERS), + this.deleteItem.bind(this) + ); + + this.router.post( + '/v1/orders/:id/transactions', + security.checkUserScope.bind(this, security.scope.WRITE_ORDERS), + this.addTransaction.bind(this) + ); + this.router.put( + '/v1/orders/:id/transactions/:transaction_id', + security.checkUserScope.bind(this, security.scope.WRITE_ORDERS), + this.updateTransaction.bind(this) + ); + this.router.delete( + '/v1/orders/:id/transactions/:transaction_id', + security.checkUserScope.bind(this, security.scope.WRITE_ORDERS), + this.deleteTransaction.bind(this) + ); + + this.router.post( + '/v1/orders/:id/discounts', + security.checkUserScope.bind(this, security.scope.WRITE_ORDERS), + this.addDiscount.bind(this) + ); + this.router.put( + '/v1/orders/:id/discounts/:discount_id', + security.checkUserScope.bind(this, security.scope.WRITE_ORDERS), + this.updateDiscount.bind(this) + ); + this.router.delete( + '/v1/orders/:id/discounts/:discount_id', + security.checkUserScope.bind(this, security.scope.WRITE_ORDERS), + this.deleteDiscount.bind(this) + ); + + this.router.get( + '/v1/orders/:id/payment_form_settings', + security.checkUserScope.bind(this, security.scope.READ_ORDERS), + this.getPaymentFormSettings.bind(this) + ); + + this.router.post( + '/v1/orders/:id/charge', + security.checkUserScope.bind(this, security.scope.WRITE_ORDERS), + this.chargeOrder.bind(this) + ); + } + + getOrders(req, res, next) { + OrdersService.getOrders(req.query) + .then(data => { + res.send(data); + }) + .catch(next); + } + + getSingleOrder(req, res, next) { + OrdersService.getSingleOrder(req.params.id) + .then(data => { + if (data) { + res.send(data); + } else { + res.status(404).end(); + } + }) + .catch(next); + } + + addOrder(req, res, next) { + OrdersService.addOrder(req.body) + .then(data => { + res.send(data); + }) + .catch(next); + } + + updateOrder(req, res, next) { + OrdersService.updateOrder(req.params.id, req.body) + .then(data => { + if (data) { + res.send(data); + } else { + res.status(404).end(); + } + }) + .catch(next); + } + + deleteOrder(req, res, next) { + OrdersService.deleteOrder(req.params.id) + .then(data => { + res.status(data ? 200 : 404).end(); + }) + .catch(next); + } + + recalculateOrder(req, res, next) { + OrderItemsService.calculateAndUpdateAllItems(req.params.id) + .then(data => { + if (data) { + res.send(data); + } else { + res.status(404).end(); + } + }) + .catch(next); + } + + checkoutOrder(req, res, next) { + OrdersService.checkoutOrder(req.params.id) + .then(data => { + res.send(data); + }) + .catch(next); + } + + cancelOrder(req, res, next) { + OrdersService.cancelOrder(req.params.id) + .then(data => { + res.send(data); + }) + .catch(next); + } + + closeOrder(req, res, next) { + OrdersService.closeOrder(req.params.id) + .then(data => { + res.send(data); + }) + .catch(next); + } + + updateBillingAddress(req, res, next) { + OrderAddressService.updateBillingAddress(req.params.id, req.body) + .then(data => { + if (data) { + res.send(data); + } else { + res.status(404).end(); + } + }) + .catch(next); + } + + updateShippingAddress(req, res, next) { + OrderAddressService.updateShippingAddress(req.params.id, req.body) + .then(data => { + if (data) { + res.send(data); + } else { + res.status(404).end(); + } + }) + .catch(next); + } + + addItem(req, res, next) { + const order_id = req.params.id; + OrderItemsService.addItem(order_id, req.body) + .then(data => { + res.send(data); + }) + .catch(next); + } + + updateItem(req, res, next) { + const order_id = req.params.id; + const item_id = req.params.item_id; + OrderItemsService.updateItem(order_id, item_id, req.body) + .then(data => { + if (data) { + res.send(data); + } else { + res.status(404).end(); + } + }) + .catch(next); + } + + deleteItem(req, res, next) { + const order_id = req.params.id; + const item_id = req.params.item_id; + OrderItemsService.deleteItem(order_id, item_id) + .then(data => { + res.send(data); + }) + .catch(next); + } + + addTransaction(req, res, next) { + const order_id = req.params.id; + OrdertTansactionsService.addTransaction(order_id, req.body) + .then(data => { + res.send(data); + }) + .catch(next); + } + + updateTransaction(req, res, next) { + const order_id = req.params.id; + const transaction_id = req.params.item_id; + OrdertTansactionsService.updateTransaction( + order_id, + transaction_id, + req.body + ) + .then(data => { + if (data) { + res.send(data); + } else { + res.status(404).end(); + } + }) + .catch(next); + } + + deleteTransaction(req, res, next) { + const order_id = req.params.id; + const transaction_id = req.params.item_id; + OrdertTansactionsService.deleteTransaction(order_id, transaction_id) + .then(data => { + res.send(data); + }) + .catch(next); + } + + addDiscount(req, res, next) { + const order_id = req.params.id; + OrdertDiscountsService.addDiscount(order_id, req.body) + .then(data => { + res.send(data); + }) + .catch(next); + } + + updateDiscount(req, res, next) { + const order_id = req.params.id; + const discount_id = req.params.item_id; + OrdertDiscountsService.updateDiscount(order_id, discount_id, req.body) + .then(data => { + if (data) { + res.send(data); + } else { + res.status(404).end(); + } + }) + .catch(next); + } + + deleteDiscount(req, res, next) { + const order_id = req.params.id; + const discount_id = req.params.item_id; + OrdertDiscountsService.deleteDiscount(order_id, discount_id) + .then(data => { + res.send(data); + }) + .catch(next); + } + + getPaymentFormSettings(req, res, next) { + const orderId = req.params.id; + PaymentGateways.getPaymentFormSettings(orderId) + .then(data => { + res.send(data); + }) + .catch(next); + } + + async chargeOrder(req, res, next) { + const orderId = req.params.id; + try { + const isSuccess = await OrdersService.chargeOrder(orderId); + res.status(isSuccess ? 200 : 500).end(); + } catch (err) { + next(err); + } + } +} + +export default OrdersRoute; diff --git a/src/api/server/routes/pages.js b/src/api/server/routes/pages.js new file mode 100755 index 0000000..9699e14 --- /dev/null +++ b/src/api/server/routes/pages.js @@ -0,0 +1,87 @@ +import security from '../lib/security'; +import PagesService from '../services/pages/pages'; + +class PagesRoute { + constructor(router) { + this.router = router; + this.registerRoutes(); + } + + registerRoutes() { + this.router.get( + '/v1/pages', + security.checkUserScope.bind(this, security.scope.READ_PAGES), + this.getPages.bind(this) + ); + this.router.post( + '/v1/pages', + security.checkUserScope.bind(this, security.scope.WRITE_PAGES), + this.addPage.bind(this) + ); + this.router.get( + '/v1/pages/:id', + security.checkUserScope.bind(this, security.scope.READ_PAGES), + this.getSinglePage.bind(this) + ); + this.router.put( + '/v1/pages/:id', + security.checkUserScope.bind(this, security.scope.WRITE_PAGES), + this.updatePage.bind(this) + ); + this.router.delete( + '/v1/pages/:id', + security.checkUserScope.bind(this, security.scope.WRITE_PAGES), + this.deletePage.bind(this) + ); + } + + getPages(req, res, next) { + PagesService.getPages(req.query) + .then(data => { + res.send(data); + }) + .catch(next); + } + + getSinglePage(req, res, next) { + PagesService.getSinglePage(req.params.id) + .then(data => { + if (data) { + res.send(data); + } else { + res.status(404).end(); + } + }) + .catch(next); + } + + addPage(req, res, next) { + PagesService.addPage(req.body) + .then(data => { + res.send(data); + }) + .catch(next); + } + + updatePage(req, res, next) { + PagesService.updatePage(req.params.id, req.body) + .then(data => { + if (data) { + res.send(data); + } else { + res.status(404).end(); + } + }) + .catch(next); + } + + deletePage(req, res, next) { + PagesService.deletePage(req.params.id) + .then(data => { + res.status(data ? 200 : 404).end(); + }) + .catch(next); + } +} + +export default PagesRoute; diff --git a/src/api/server/routes/paymentGateways.js b/src/api/server/routes/paymentGateways.js new file mode 100755 index 0000000..2af8f0c --- /dev/null +++ b/src/api/server/routes/paymentGateways.js @@ -0,0 +1,40 @@ +import security from '../lib/security'; +import PaymentGatewaysService from '../services/settings/paymentGateways'; + +class PaymentGatewaysRoute { + constructor(router) { + this.router = router; + this.registerRoutes(); + } + + registerRoutes() { + this.router.get( + '/v1/payment_gateways/:name', + security.checkUserScope.bind(this, security.scope.READ_PAYMENT_METHODS), + this.getGateway.bind(this) + ); + this.router.put( + '/v1/payment_gateways/:name', + security.checkUserScope.bind(this, security.scope.WRITE_PAYMENT_METHODS), + this.updateGateway.bind(this) + ); + } + + getGateway(req, res, next) { + PaymentGatewaysService.getGateway(req.params.name) + .then(data => { + res.send(data); + }) + .catch(next); + } + + updateGateway(req, res, next) { + PaymentGatewaysService.updateGateway(req.params.name, req.body) + .then(data => { + res.send(data); + }) + .catch(next); + } +} + +export default PaymentGatewaysRoute; diff --git a/src/api/server/routes/paymentMethods.js b/src/api/server/routes/paymentMethods.js new file mode 100755 index 0000000..47bf663 --- /dev/null +++ b/src/api/server/routes/paymentMethods.js @@ -0,0 +1,87 @@ +import security from '../lib/security'; +import PaymentMethodsService from '../services/orders/paymentMethods'; + +class PaymentMethodsRoute { + constructor(router) { + this.router = router; + this.registerRoutes(); + } + + registerRoutes() { + this.router.get( + '/v1/payment_methods', + security.checkUserScope.bind(this, security.scope.READ_PAYMENT_METHODS), + this.getMethods.bind(this) + ); + this.router.post( + '/v1/payment_methods', + security.checkUserScope.bind(this, security.scope.WRITE_PAYMENT_METHODS), + this.addMethod.bind(this) + ); + this.router.get( + '/v1/payment_methods/:id', + security.checkUserScope.bind(this, security.scope.READ_PAYMENT_METHODS), + this.getSingleMethod.bind(this) + ); + this.router.put( + '/v1/payment_methods/:id', + security.checkUserScope.bind(this, security.scope.WRITE_PAYMENT_METHODS), + this.updateMethod.bind(this) + ); + this.router.delete( + '/v1/payment_methods/:id', + security.checkUserScope.bind(this, security.scope.WRITE_PAYMENT_METHODS), + this.deleteMethod.bind(this) + ); + } + + getMethods(req, res, next) { + PaymentMethodsService.getMethods(req.query) + .then(data => { + res.send(data); + }) + .catch(next); + } + + getSingleMethod(req, res, next) { + PaymentMethodsService.getSingleMethod(req.params.id) + .then(data => { + if (data) { + res.send(data); + } else { + res.status(404).end(); + } + }) + .catch(next); + } + + addMethod(req, res, next) { + PaymentMethodsService.addMethod(req.body) + .then(data => { + res.send(data); + }) + .catch(next); + } + + updateMethod(req, res, next) { + PaymentMethodsService.updateMethod(req.params.id, req.body) + .then(data => { + if (data) { + res.send(data); + } else { + res.status(404).end(); + } + }) + .catch(next); + } + + deleteMethod(req, res, next) { + PaymentMethodsService.deleteMethod(req.params.id) + .then(data => { + res.status(data ? 200 : 404).end(); + }) + .catch(next); + } +} + +export default PaymentMethodsRoute; diff --git a/src/api/server/routes/productCategories.js b/src/api/server/routes/productCategories.js new file mode 100755 index 0000000..970e627 --- /dev/null +++ b/src/api/server/routes/productCategories.js @@ -0,0 +1,127 @@ +import security from '../lib/security'; +import CategoriesService from '../services/products/productCategories'; + +class ProductCategoriesRoute { + constructor(router) { + this.router = router; + this.registerRoutes(); + } + + registerRoutes() { + this.router.get( + '/v1/product_categories', + security.checkUserScope.bind( + this, + security.scope.READ_PRODUCT_CATEGORIES + ), + this.getCategories.bind(this) + ); + this.router.post( + '/v1/product_categories', + security.checkUserScope.bind( + this, + security.scope.WRITE_PRODUCT_CATEGORIES + ), + this.addCategory.bind(this) + ); + this.router.get( + '/v1/product_categories/:id', + security.checkUserScope.bind( + this, + security.scope.READ_PRODUCT_CATEGORIES + ), + this.getSingleCategory.bind(this) + ); + this.router.put( + '/v1/product_categories/:id', + security.checkUserScope.bind( + this, + security.scope.WRITE_PRODUCT_CATEGORIES + ), + this.updateCategory.bind(this) + ); + this.router.delete( + '/v1/product_categories/:id', + security.checkUserScope.bind( + this, + security.scope.WRITE_PRODUCT_CATEGORIES + ), + this.deleteCategory.bind(this) + ); + this.router.post( + '/v1/product_categories/:id/image', + security.checkUserScope.bind( + this, + security.scope.WRITE_PRODUCT_CATEGORIES + ), + this.uploadCategoryImage.bind(this) + ); + this.router.delete( + '/v1/product_categories/:id/image', + security.checkUserScope.bind( + this, + security.scope.WRITE_PRODUCT_CATEGORIES + ), + this.deleteCategoryImage.bind(this) + ); + } + + getCategories(req, res, next) { + CategoriesService.getCategories(req.query) + .then(data => { + res.send(data); + }) + .catch(next); + } + + getSingleCategory(req, res, next) { + CategoriesService.getSingleCategory(req.params.id) + .then(data => { + if (data) { + res.send(data); + } else { + res.status(404).end(); + } + }) + .catch(next); + } + + addCategory(req, res, next) { + CategoriesService.addCategory(req.body) + .then(data => { + res.send(data); + }) + .catch(next); + } + + updateCategory(req, res, next) { + CategoriesService.updateCategory(req.params.id, req.body) + .then(data => { + if (data) { + res.send(data); + } else { + res.status(404).end(); + } + }) + .catch(next); + } + + deleteCategory(req, res, next) { + CategoriesService.deleteCategory(req.params.id) + .then(data => { + res.status(data ? 200 : 404).end(); + }) + .catch(next); + } + + uploadCategoryImage(req, res, next) { + CategoriesService.uploadCategoryImage(req, res, next); + } + + deleteCategoryImage(req, res, next) { + CategoriesService.deleteCategoryImage(req.params.id); + res.end(); + } +} + +export default ProductCategoriesRoute; diff --git a/src/api/server/routes/products.js b/src/api/server/routes/products.js new file mode 100755 index 0000000..64d05f1 --- /dev/null +++ b/src/api/server/routes/products.js @@ -0,0 +1,417 @@ +import security from '../lib/security'; +import ProductsService from '../services/products/products'; +import ProductOptionsService from '../services/products/options'; +import ProductOptionValuesService from '../services/products/optionValues'; +import ProductVariantsService from '../services/products/variants'; +import ProductImagesService from '../services/products/images'; + +class ProductsRoute { + constructor(router) { + this.router = router; + this.registerRoutes(); + } + + registerRoutes() { + this.router.get( + '/v1/products', + security.checkUserScope.bind(this, security.scope.READ_PRODUCTS), + this.getProducts.bind(this) + ); + this.router.post( + '/v1/products', + security.checkUserScope.bind(this, security.scope.WRITE_PRODUCTS), + this.addProduct.bind(this) + ); + this.router.get( + '/v1/products/:productId', + security.checkUserScope.bind(this, security.scope.READ_PRODUCTS), + this.getSingleProduct.bind(this) + ); + this.router.put( + '/v1/products/:productId', + security.checkUserScope.bind(this, security.scope.WRITE_PRODUCTS), + this.updateProduct.bind(this) + ); + this.router.delete( + '/v1/products/:productId', + security.checkUserScope.bind(this, security.scope.WRITE_PRODUCTS), + this.deleteProduct.bind(this) + ); + + this.router.get( + '/v1/products/:productId/images', + security.checkUserScope.bind(this, security.scope.READ_PRODUCTS), + this.getImages.bind(this) + ); + this.router.post( + '/v1/products/:productId/images', + security.checkUserScope.bind(this, security.scope.WRITE_PRODUCTS), + this.addImage.bind(this) + ); + this.router.put( + '/v1/products/:productId/images/:imageId', + security.checkUserScope.bind(this, security.scope.WRITE_PRODUCTS), + this.updateImage.bind(this) + ); + this.router.delete( + '/v1/products/:productId/images/:imageId', + security.checkUserScope.bind(this, security.scope.WRITE_PRODUCTS), + this.deleteImage.bind(this) + ); + + this.router.get( + '/v1/products/:productId/sku', + security.checkUserScope.bind(this, security.scope.READ_PRODUCTS), + this.isSkuExists.bind(this) + ); + this.router.get( + '/v1/products/:productId/slug', + security.checkUserScope.bind(this, security.scope.READ_PRODUCTS), + this.isSlugExists.bind(this) + ); + + this.router.get( + '/v1/products/:productId/options', + security.checkUserScope.bind(this, security.scope.READ_PRODUCTS), + this.getOptions.bind(this) + ); + this.router.get( + '/v1/products/:productId/options/:optionId', + security.checkUserScope.bind(this, security.scope.READ_PRODUCTS), + this.getSingleOption.bind(this) + ); + this.router.post( + '/v1/products/:productId/options', + security.checkUserScope.bind(this, security.scope.WRITE_PRODUCTS), + this.addOption.bind(this) + ); + this.router.put( + '/v1/products/:productId/options/:optionId', + security.checkUserScope.bind(this, security.scope.WRITE_PRODUCTS), + this.updateOption.bind(this) + ); + this.router.delete( + '/v1/products/:productId/options/:optionId', + security.checkUserScope.bind(this, security.scope.WRITE_PRODUCTS), + this.deleteOption.bind(this) + ); + + this.router.get( + '/v1/products/:productId/options/:optionId/values', + security.checkUserScope.bind(this, security.scope.READ_PRODUCTS), + this.getOptionValues.bind(this) + ); + this.router.get( + '/v1/products/:productId/options/:optionId/values/:valueId', + security.checkUserScope.bind(this, security.scope.READ_PRODUCTS), + this.getSingleOptionValue.bind(this) + ); + this.router.post( + '/v1/products/:productId/options/:optionId/values', + security.checkUserScope.bind(this, security.scope.WRITE_PRODUCTS), + this.addOptionValue.bind(this) + ); + this.router.put( + '/v1/products/:productId/options/:optionId/values/:valueId', + security.checkUserScope.bind(this, security.scope.WRITE_PRODUCTS), + this.updateOptionValue.bind(this) + ); + this.router.delete( + '/v1/products/:productId/options/:optionId/values/:valueId', + security.checkUserScope.bind(this, security.scope.WRITE_PRODUCTS), + this.deleteOptionValue.bind(this) + ); + + this.router.get( + '/v1/products/:productId/variants', + security.checkUserScope.bind(this, security.scope.READ_PRODUCTS), + this.getVariants.bind(this) + ); + this.router.post( + '/v1/products/:productId/variants', + security.checkUserScope.bind(this, security.scope.WRITE_PRODUCTS), + this.addVariant.bind(this) + ); + this.router.put( + '/v1/products/:productId/variants/:variantId', + security.checkUserScope.bind(this, security.scope.WRITE_PRODUCTS), + this.updateVariant.bind(this) + ); + this.router.delete( + '/v1/products/:productId/variants/:variantId', + security.checkUserScope.bind(this, security.scope.WRITE_PRODUCTS), + this.deleteVariant.bind(this) + ); + this.router.put( + '/v1/products/:productId/variants/:variantId/options', + security.checkUserScope.bind(this, security.scope.WRITE_PRODUCTS), + this.setVariantOption.bind(this) + ); + } + + getProducts(req, res, next) { + ProductsService.getProducts(req.query) + .then(data => { + res.send(data); + }) + .catch(next); + } + + getSingleProduct(req, res, next) { + ProductsService.getSingleProduct(req.params.productId) + .then(data => { + if (data) { + res.send(data); + } else { + res.status(404).end(); + } + }) + .catch(next); + } + + addProduct(req, res, next) { + ProductsService.addProduct(req.body) + .then(data => { + res.send(data); + }) + .catch(next); + } + + updateProduct(req, res, next) { + ProductsService.updateProduct(req.params.productId, req.body) + .then(data => { + if (data) { + res.send(data); + } else { + res.status(404).end(); + } + }) + .catch(next); + } + + deleteProduct(req, res, next) { + ProductsService.deleteProduct(req.params.productId) + .then(data => { + res.status(data ? 200 : 404).end(); + }) + .catch(next); + } + + getImages(req, res, next) { + ProductImagesService.getImages(req.params.productId) + .then(data => { + res.send(data); + }) + .catch(next); + } + + async addImage(req, res, next) { + await ProductImagesService.addImage(req, res, next); + } + + updateImage(req, res, next) { + ProductImagesService.updateImage( + req.params.productId, + req.params.imageId, + req.body + ).then(data => { + res.end(); + }); + } + + deleteImage(req, res, next) { + ProductImagesService.deleteImage( + req.params.productId, + req.params.imageId + ).then(data => { + res.end(); + }); + } + + isSkuExists(req, res, next) { + ProductsService.isSkuExists(req.query.sku, req.params.productId) + .then(exists => { + res.status(exists ? 200 : 404).end(); + }) + .catch(next); + } + + isSlugExists(req, res, next) { + ProductsService.isSlugExists(req.query.slug, req.params.productId) + .then(exists => { + res.status(exists ? 200 : 404).end(); + }) + .catch(next); + } + + getOptions(req, res, next) { + ProductOptionsService.getOptions(req.params.productId) + .then(data => { + res.send(data); + }) + .catch(next); + } + + getSingleOption(req, res, next) { + ProductOptionsService.getSingleOption( + req.params.productId, + req.params.optionId + ) + .then(data => { + if (data) { + res.send(data); + } else { + res.status(404).end(); + } + }) + .catch(next); + } + + addOption(req, res, next) { + ProductOptionsService.addOption(req.params.productId, req.body) + .then(data => { + res.send(data); + }) + .catch(next); + } + + updateOption(req, res, next) { + ProductOptionsService.updateOption( + req.params.productId, + req.params.optionId, + req.body + ) + .then(data => { + res.send(data); + }) + .catch(next); + } + + deleteOption(req, res, next) { + ProductOptionsService.deleteOption( + req.params.productId, + req.params.optionId + ) + .then(data => { + res.send(data); + }) + .catch(next); + } + + getOptionValues(req, res, next) { + ProductOptionValuesService.getOptionValues( + req.params.productId, + req.params.optionId + ) + .then(data => { + res.send(data); + }) + .catch(next); + } + + getSingleOptionValue(req, res, next) { + ProductOptionValuesService.getSingleOptionValue( + req.params.productId, + req.params.optionId, + req.params.valueId + ) + .then(data => { + if (data) { + res.send(data); + } else { + res.status(404).end(); + } + }) + .catch(next); + } + + addOptionValue(req, res, next) { + ProductOptionValuesService.addOptionValue( + req.params.productId, + req.params.optionId, + req.body + ) + .then(data => { + res.send(data); + }) + .catch(next); + } + + updateOptionValue(req, res, next) { + ProductOptionValuesService.updateOptionValue( + req.params.productId, + req.params.optionId, + req.params.valueId, + req.body + ) + .then(data => { + res.send(data); + }) + .catch(next); + } + + deleteOptionValue(req, res, next) { + ProductOptionValuesService.deleteOptionValue( + req.params.productId, + req.params.optionId, + req.params.valueId + ) + .then(data => { + res.send(data); + }) + .catch(next); + } + + getVariants(req, res, next) { + ProductVariantsService.getVariants(req.params.productId) + .then(data => { + res.send(data); + }) + .catch(next); + } + + addVariant(req, res, next) { + ProductVariantsService.addVariant(req.params.productId, req.body) + .then(data => { + res.send(data); + }) + .catch(next); + } + + updateVariant(req, res, next) { + ProductVariantsService.updateVariant( + req.params.productId, + req.params.variantId, + req.body + ) + .then(data => { + res.send(data); + }) + .catch(next); + } + + deleteVariant(req, res, next) { + ProductVariantsService.deleteVariant( + req.params.productId, + req.params.variantId + ) + .then(data => { + res.send(data); + }) + .catch(next); + } + + setVariantOption(req, res, next) { + ProductVariantsService.setVariantOption( + req.params.productId, + req.params.variantId, + req.body + ) + .then(data => { + res.send(data); + }) + .catch(next); + } +} + +export default ProductsRoute; diff --git a/src/api/server/routes/redirects.js b/src/api/server/routes/redirects.js new file mode 100755 index 0000000..816ba72 --- /dev/null +++ b/src/api/server/routes/redirects.js @@ -0,0 +1,87 @@ +import security from '../lib/security'; +import RedirectsService from '../services/redirects'; + +class RedirectsRoute { + constructor(router) { + this.router = router; + this.registerRoutes(); + } + + registerRoutes() { + this.router.get( + '/v1/redirects', + security.checkUserScope.bind(this, security.scope.READ_SETTINGS), + this.getRedirects.bind(this) + ); + this.router.post( + '/v1/redirects', + security.checkUserScope.bind(this, security.scope.WRITE_SETTINGS), + this.addRedirect.bind(this) + ); + this.router.get( + '/v1/redirects/:id', + security.checkUserScope.bind(this, security.scope.READ_SETTINGS), + this.getSingleRedirect.bind(this) + ); + this.router.put( + '/v1/redirects/:id', + security.checkUserScope.bind(this, security.scope.WRITE_SETTINGS), + this.updateRedirect.bind(this) + ); + this.router.delete( + '/v1/redirects/:id', + security.checkUserScope.bind(this, security.scope.WRITE_SETTINGS), + this.deleteRedirect.bind(this) + ); + } + + getRedirects(req, res, next) { + RedirectsService.getRedirects(req.query) + .then(data => { + res.send(data); + }) + .catch(next); + } + + getSingleRedirect(req, res, next) { + RedirectsService.getSingleRedirect(req.params.id) + .then(data => { + if (data) { + res.send(data); + } else { + res.status(404).end(); + } + }) + .catch(next); + } + + addRedirect(req, res, next) { + RedirectsService.addRedirect(req.body) + .then(data => { + res.send(data); + }) + .catch(next); + } + + updateRedirect(req, res, next) { + RedirectsService.updateRedirect(req.params.id, req.body) + .then(data => { + if (data) { + res.send(data); + } else { + res.status(404).end(); + } + }) + .catch(next); + } + + deleteRedirect(req, res, next) { + RedirectsService.deleteRedirect(req.params.id) + .then(data => { + res.status(data ? 200 : 404).end(); + }) + .catch(next); + } +} + +export default RedirectsRoute; diff --git a/src/api/server/routes/settings.js b/src/api/server/routes/settings.js new file mode 100755 index 0000000..c07f858 --- /dev/null +++ b/src/api/server/routes/settings.js @@ -0,0 +1,170 @@ +import security from '../lib/security'; +import SettingsService from '../services/settings/settings'; +import EmailSettingsService from '../services/settings/email'; +import EmailTemplatesService from '../services/settings/emailTemplates'; +import CheckoutFieldsService from '../services/settings/checkoutFields'; + +class SettingsRoute { + constructor(router) { + this.router = router; + this.registerRoutes(); + } + + registerRoutes() { + this.router.get( + '/v1/settings', + security.checkUserScope.bind(this, security.scope.READ_SETTINGS), + this.getSettings.bind(this) + ); + this.router.put( + '/v1/settings', + security.checkUserScope.bind(this, security.scope.WRITE_SETTINGS), + this.updateSettings.bind(this) + ); + this.router.get( + '/v1/settings/email', + security.checkUserScope.bind(this, security.scope.READ_SETTINGS), + this.getEmailSettings.bind(this) + ); + this.router.put( + '/v1/settings/email', + security.checkUserScope.bind(this, security.scope.WRITE_SETTINGS), + this.updateEmailSettings.bind(this) + ); + this.router.get( + '/v1/settings/email/templates/:name', + security.checkUserScope.bind(this, security.scope.READ_SETTINGS), + this.getEmailTemplate.bind(this) + ); + this.router.put( + '/v1/settings/email/templates/:name', + security.checkUserScope.bind(this, security.scope.WRITE_SETTINGS), + this.updateEmailTemplate.bind(this) + ); + this.router.get( + '/v1/settings/checkout/fields', + security.checkUserScope.bind(this, security.scope.READ_SETTINGS), + this.getCheckoutFields.bind(this) + ); + this.router.get( + '/v1/settings/checkout/fields/:name', + security.checkUserScope.bind(this, security.scope.READ_SETTINGS), + this.getCheckoutField.bind(this) + ); + this.router.put( + '/v1/settings/checkout/fields/:name', + security.checkUserScope.bind(this, security.scope.WRITE_SETTINGS), + this.updateCheckoutField.bind(this) + ); + this.router.post( + '/v1/settings/logo', + security.checkUserScope.bind(this, security.scope.WRITE_SETTINGS), + this.uploadLogo.bind(this) + ); + this.router.delete( + '/v1/settings/logo', + security.checkUserScope.bind(this, security.scope.WRITE_SETTINGS), + this.deleteLogo.bind(this) + ); + } + + getSettings(req, res, next) { + SettingsService.getSettings() + .then(data => { + res.send(data); + }) + .catch(next); + } + + updateSettings(req, res, next) { + SettingsService.updateSettings(req.body) + .then(data => { + if (data) { + res.send(data); + } else { + res.status(404).end(); + } + }) + .catch(next); + } + + getEmailSettings(req, res, next) { + EmailSettingsService.getEmailSettings() + .then(data => { + res.send(data); + }) + .catch(next); + } + + updateEmailSettings(req, res, next) { + EmailSettingsService.updateEmailSettings(req.body) + .then(data => { + if (data) { + res.send(data); + } else { + res.status(404).end(); + } + }) + .catch(next); + } + + getEmailTemplate(req, res, next) { + EmailTemplatesService.getEmailTemplate(req.params.name) + .then(data => { + res.send(data); + }) + .catch(next); + } + + updateEmailTemplate(req, res, next) { + EmailTemplatesService.updateEmailTemplate(req.params.name, req.body) + .then(data => { + if (data) { + res.send(data); + } else { + res.status(404).end(); + } + }) + .catch(next); + } + + getCheckoutFields(req, res, next) { + CheckoutFieldsService.getCheckoutFields() + .then(data => { + res.send(data); + }) + .catch(next); + } + + getCheckoutField(req, res, next) { + CheckoutFieldsService.getCheckoutField(req.params.name) + .then(data => { + res.send(data); + }) + .catch(next); + } + + updateCheckoutField(req, res, next) { + CheckoutFieldsService.updateCheckoutField(req.params.name, req.body) + .then(data => { + if (data) { + res.send(data); + } else { + res.status(404).end(); + } + }) + .catch(next); + } + + uploadLogo(req, res, next) { + SettingsService.uploadLogo(req, res, next); + } + + deleteLogo(req, res, next) { + SettingsService.deleteLogo().then(() => { + res.end(); + }); + } +} + +export default SettingsRoute; diff --git a/src/api/server/routes/shippingMethods.js b/src/api/server/routes/shippingMethods.js new file mode 100755 index 0000000..b7ad1d5 --- /dev/null +++ b/src/api/server/routes/shippingMethods.js @@ -0,0 +1,84 @@ +import security from '../lib/security'; +import ShippingMethodsService from '../services/orders/shippingMethods'; + +class ShippingMethodsRoute { + constructor(router) { + this.router = router; + this.registerRoutes(); + } + + registerRoutes() { + this.router.get( + '/v1/shipping_methods', + security.checkUserScope.bind(this, security.scope.READ_SHIPPING_METHODS), + this.getMethods.bind(this) + ); + this.router.post( + '/v1/shipping_methods', + security.checkUserScope.bind(this, security.scope.WRITE_SHIPPING_METHODS), + this.addMethod.bind(this) + ); + this.router.get( + '/v1/shipping_methods/:id', + security.checkUserScope.bind(this, security.scope.READ_SHIPPING_METHODS), + this.getSingleMethod.bind(this) + ); + this.router.put( + '/v1/shipping_methods/:id', + security.checkUserScope.bind(this, security.scope.WRITE_SHIPPING_METHODS), + this.updateMethod.bind(this) + ); + this.router.delete( + '/v1/shipping_methods/:id', + security.checkUserScope.bind(this, security.scope.WRITE_SHIPPING_METHODS), + this.deleteMethod.bind(this) + ); + } + + getMethods(req, res, next) { + ShippingMethodsService.getMethods(req.query) + .then(data => { + res.send(data); + }) + .catch(next); + } + + getSingleMethod(req, res, next) { + ShippingMethodsService.getSingleMethod(req.params.id) + .then(data => { + if (data) { + res.send(data); + } else { + res.status(404).end(); + } + }) + .catch(next); + } + + addMethod(req, res, next) { + ShippingMethodsService.addMethod(req.body) + .then(data => { + res.send(data); + }) + .catch(next); + } + + updateMethod(req, res, next) { + ShippingMethodsService.updateMethod(req.params.id, req.body) + .then(data => { + if (data) { + res.send(data); + } else { + res.status(404).end(); + } + }) + .catch(next); + } + + async deleteMethod(req, res, next) { + const result = await ShippingMethodsService.deleteMethod(req.params.id); + res.status(result ? 200 : 404).end(); + } +} + +export default ShippingMethodsRoute; diff --git a/src/api/server/routes/sitemap.js b/src/api/server/routes/sitemap.js new file mode 100755 index 0000000..1b651fc --- /dev/null +++ b/src/api/server/routes/sitemap.js @@ -0,0 +1,39 @@ +import security from '../lib/security'; +import SitemapService from '../services/sitemap'; + +class SitemapRoute { + constructor(router) { + this.router = router; + this.registerRoutes(); + } + + registerRoutes() { + this.router.get( + '/v1/sitemap', + security.checkUserScope.bind(this, security.scope.READ_SITEMAP), + this.getPaths.bind(this) + ); + } + + getPaths(req, res, next) { + if (req.query.path) { + SitemapService.getSinglePath(req.query.path, req.query.enabled) + .then(data => { + if (data) { + res.send(data); + } else { + res.status(404).end(); + } + }) + .catch(next); + } else { + SitemapService.getPaths(req.query.enabled) + .then(data => { + res.send(data); + }) + .catch(next); + } + } +} + +export default SitemapRoute; diff --git a/src/api/server/routes/theme.js b/src/api/server/routes/theme.js new file mode 100755 index 0000000..73cdb45 --- /dev/null +++ b/src/api/server/routes/theme.js @@ -0,0 +1,172 @@ +import security from '../lib/security'; +import ThemeService from '../services/theme/theme'; +import ThemeSettingsService from '../services/theme/settings'; +import ThemeAssetsService from '../services/theme/assets'; +import ThemePlaceholdersService from '../services/theme/placeholders'; + +class ThemeRoute { + constructor(router) { + this.router = router; + this.registerRoutes(); + } + + registerRoutes() { + this.router.get( + '/v1/theme/export', + security.checkUserScope.bind(this, security.scope.READ_THEME), + this.exportTheme.bind(this) + ); + this.router.post( + '/v1/theme/install', + security.checkUserScope.bind(this, security.scope.WRITE_THEME), + this.installTheme.bind(this) + ); + + this.router.get( + '/v1/theme/settings', + security.checkUserScope.bind(this, security.scope.READ_THEME), + this.getSettings.bind(this) + ); + this.router.put( + '/v1/theme/settings', + security.checkUserScope.bind(this, security.scope.WRITE_THEME), + this.updateSettings.bind(this) + ); + this.router.get( + '/v1/theme/settings_schema', + security.checkUserScope.bind(this, security.scope.READ_THEME), + this.getSettingsSchema.bind(this) + ); + + this.router.post( + '/v1/theme/assets', + security.checkUserScope.bind(this, security.scope.WRITE_THEME), + this.uploadFile.bind(this) + ); + this.router.delete( + '/v1/theme/assets/:file', + security.checkUserScope.bind(this, security.scope.WRITE_THEME), + this.deleteFile.bind(this) + ); + + this.router.get( + '/v1/theme/placeholders', + security.checkUserScope.bind(this, security.scope.READ_THEME), + this.getPlaceholders.bind(this) + ); + this.router.post( + '/v1/theme/placeholders', + security.checkUserScope.bind(this, security.scope.WRITE_THEME), + this.addPlaceholder.bind(this) + ); + this.router.get( + '/v1/theme/placeholders/:key', + security.checkUserScope.bind(this, security.scope.READ_THEME), + this.getSinglePlaceholder.bind(this) + ); + this.router.put( + '/v1/theme/placeholders/:key', + security.checkUserScope.bind(this, security.scope.WRITE_THEME), + this.updatePlaceholder.bind(this) + ); + this.router.delete( + '/v1/theme/placeholders/:key', + security.checkUserScope.bind(this, security.scope.WRITE_THEME), + this.deletePlaceholder.bind(this) + ); + } + + exportTheme(req, res, next) { + ThemeService.exportTheme(req, res); + } + + installTheme(req, res, next) { + ThemeService.installTheme(req, res); + } + + getSettings(req, res, next) { + ThemeSettingsService.getSettings() + .then(data => { + res.send(data); + }) + .catch(next); + } + + updateSettings(req, res, next) { + ThemeSettingsService.updateSettings(req.body) + .then(() => { + res.end(); + }) + .catch(next); + } + + getSettingsSchema(req, res, next) { + ThemeSettingsService.getSettingsSchema() + .then(data => { + res.send(data); + }) + .catch(next); + } + + uploadFile(req, res, next) { + ThemeAssetsService.uploadFile(req, res, next); + } + + deleteFile(req, res, next) { + ThemeAssetsService.deleteFile(req.params.file) + .then(() => { + res.end(); + }) + .catch(next); + } + + getPlaceholders(req, res, next) { + ThemePlaceholdersService.getPlaceholders() + .then(data => { + res.send(data); + }) + .catch(next); + } + + getSinglePlaceholder(req, res, next) { + ThemePlaceholdersService.getSinglePlaceholder(req.params.key) + .then(data => { + if (data) { + res.send(data); + } else { + res.status(404).end(); + } + }) + .catch(next); + } + + addPlaceholder(req, res, next) { + ThemePlaceholdersService.addPlaceholder(req.body) + .then(data => { + res.send(data); + }) + .catch(next); + } + + updatePlaceholder(req, res, next) { + ThemePlaceholdersService.updatePlaceholder(req.params.key, req.body) + .then(data => { + if (data) { + res.send(data); + } else { + res.status(404).end(); + } + }) + .catch(next); + } + + deletePlaceholder(req, res, next) { + ThemePlaceholdersService.deletePlaceholder(req.params.key) + .then(data => { + res.status(data ? 200 : 404).end(); + }) + .catch(next); + } +} + +export default ThemeRoute; diff --git a/src/api/server/routes/tokens.js b/src/api/server/routes/tokens.js new file mode 100755 index 0000000..f4a0b2a --- /dev/null +++ b/src/api/server/routes/tokens.js @@ -0,0 +1,109 @@ +import security from '../lib/security'; +import SecurityTokensService from '../services/security/tokens'; + +class SecurityTokensRoute { + constructor(router) { + this.router = router; + this.registerRoutes(); + } + + registerRoutes() { + this.router.get( + '/v1/security/tokens', + security.checkUserScope.bind(this, security.scope.ADMIN), + this.getTokens.bind(this) + ); + this.router.get( + '/v1/security/tokens/blacklist', + security.checkUserScope.bind(this, security.scope.ADMIN), + this.getTokensBlacklist.bind(this) + ); + this.router.post( + '/v1/security/tokens', + security.checkUserScope.bind(this, security.scope.ADMIN), + this.addToken.bind(this) + ); + this.router.get( + '/v1/security/tokens/:id', + security.checkUserScope.bind(this, security.scope.ADMIN), + this.getSingleToken.bind(this) + ); + this.router.put( + '/v1/security/tokens/:id', + security.checkUserScope.bind(this, security.scope.ADMIN), + this.updateToken.bind(this) + ); + this.router.delete( + '/v1/security/tokens/:id', + security.checkUserScope.bind(this, security.scope.ADMIN), + this.deleteToken.bind(this) + ); + this.router.post('/v1/authorize', this.sendDashboardSigninUrl.bind(this)); + } + + getTokens(req, res, next) { + SecurityTokensService.getTokens(req.query) + .then(data => { + res.send(data); + }) + .catch(next); + } + + getTokensBlacklist(req, res, next) { + SecurityTokensService.getTokensBlacklist() + .then(data => { + res.send(data); + }) + .catch(next); + } + + getSingleToken(req, res, next) { + SecurityTokensService.getSingleToken(req.params.id) + .then(data => { + if (data) { + res.send(data); + } else { + res.status(404).end(); + } + }) + .catch(next); + } + + addToken(req, res, next) { + SecurityTokensService.addToken(req.body) + .then(data => { + res.send(data); + }) + .catch(next); + } + + updateToken(req, res, next) { + SecurityTokensService.updateToken(req.params.id, req.body) + .then(data => { + if (data) { + res.send(data); + } else { + res.status(404).end(); + } + }) + .catch(next); + } + + deleteToken(req, res, next) { + SecurityTokensService.deleteToken(req.params.id) + .then(data => { + res.end(); + }) + .catch(next); + } + + sendDashboardSigninUrl(req, res, next) { + SecurityTokensService.sendDashboardSigninUrl(req) + .then(data => { + res.send(data); + }) + .catch(next); + } +} + +export default SecurityTokensRoute; diff --git a/src/api/server/routes/webhooks.js b/src/api/server/routes/webhooks.js new file mode 100755 index 0000000..b73aa93 --- /dev/null +++ b/src/api/server/routes/webhooks.js @@ -0,0 +1,87 @@ +import security from '../lib/security'; +import WebhooksService from '../services/webhooks'; + +class WebhooksRoute { + constructor(router) { + this.router = router; + this.registerRoutes(); + } + + registerRoutes() { + this.router.get( + '/v1/webhooks', + security.checkUserScope.bind(this, security.scope.READ_SETTINGS), + this.getWebhooks.bind(this) + ); + this.router.post( + '/v1/webhooks', + security.checkUserScope.bind(this, security.scope.WRITE_SETTINGS), + this.addWebhook.bind(this) + ); + this.router.get( + '/v1/webhooks/:id', + security.checkUserScope.bind(this, security.scope.READ_SETTINGS), + this.getSingleWebhook.bind(this) + ); + this.router.put( + '/v1/webhooks/:id', + security.checkUserScope.bind(this, security.scope.WRITE_SETTINGS), + this.updateWebhook.bind(this) + ); + this.router.delete( + '/v1/webhooks/:id', + security.checkUserScope.bind(this, security.scope.WRITE_SETTINGS), + this.deleteWebhook.bind(this) + ); + } + + getWebhooks(req, res, next) { + WebhooksService.getWebhooks(req.query) + .then(data => { + res.send(data); + }) + .catch(next); + } + + getSingleWebhook(req, res, next) { + WebhooksService.getSingleWebhook(req.params.id) + .then(data => { + if (data) { + res.send(data); + } else { + res.status(404).end(); + } + }) + .catch(next); + } + + addWebhook(req, res, next) { + WebhooksService.addWebhook(req.body) + .then(data => { + res.send(data); + }) + .catch(next); + } + + updateWebhook(req, res, next) { + WebhooksService.updateWebhook(req.params.id, req.body) + .then(data => { + if (data) { + res.send(data); + } else { + res.status(404).end(); + } + }) + .catch(next); + } + + deleteWebhook(req, res, next) { + WebhooksService.deleteWebhook(req.params.id) + .then(data => { + res.status(data ? 200 : 404).end(); + }) + .catch(next); + } +} + +export default WebhooksRoute; diff --git a/src/api/server/services/apps/settings.js b/src/api/server/services/apps/settings.js new file mode 100755 index 0000000..5188662 --- /dev/null +++ b/src/api/server/services/apps/settings.js @@ -0,0 +1,33 @@ +import { db } from '../../lib/mongo'; +import parse from '../../lib/parse'; + +class AppSettingsService { + constructor() {} + + getSettings(appKey) { + return db + .collection('appSettings') + .findOne({ key: appKey }, { _id: 0, key: 0 }); + } + + updateSettings(appKey, data) { + if (Object.keys(data).length === 0) { + return new Error('Required fields are missing'); + } + + delete data.key; + + return db + .collection('appSettings') + .updateOne( + { key: appKey }, + { + $set: data + }, + { upsert: true } + ) + .then(res => this.getSettings(appKey)); + } +} + +export default new AppSettingsService(); diff --git a/src/api/server/services/customers/customerGroups.js b/src/api/server/services/customers/customerGroups.js new file mode 100755 index 0000000..e859ad0 --- /dev/null +++ b/src/api/server/services/customers/customerGroups.js @@ -0,0 +1,109 @@ +import { ObjectID } from 'mongodb'; +import { db } from '../../lib/mongo'; +import utils from '../../lib/utils'; +import parse from '../../lib/parse'; + +class CustomerGroupsService { + constructor() {} + + getGroups(params = {}) { + return db + .collection('customerGroups') + .find() + .toArray() + .then(items => items.map(item => this.changeProperties(item))); + } + + getSingleGroup(id) { + if (!ObjectID.isValid(id)) { + return Promise.reject('Invalid identifier'); + } + let groupObjectID = new ObjectID(id); + + return db + .collection('customerGroups') + .findOne({ _id: groupObjectID }) + .then(item => this.changeProperties(item)); + } + + addGroup(data) { + const group = this.getValidDocumentForInsert(data); + return db + .collection('customerGroups') + .insertMany([group]) + .then(res => this.getSingleGroup(res.ops[0]._id.toString())); + } + + updateGroup(id, data) { + if (!ObjectID.isValid(id)) { + return Promise.reject('Invalid identifier'); + } + const groupObjectID = new ObjectID(id); + const group = this.getValidDocumentForUpdate(id, data); + + return db + .collection('customerGroups') + .updateOne( + { + _id: groupObjectID + }, + { $set: group } + ) + .then(res => this.getSingleGroup(id)); + } + + deleteGroup(id) { + if (!ObjectID.isValid(id)) { + return Promise.reject('Invalid identifier'); + } + const groupObjectID = new ObjectID(id); + return db + .collection('customerGroups') + .deleteOne({ _id: groupObjectID }) + .then(deleteResponse => { + return deleteResponse.deletedCount > 0; + }); + } + + getValidDocumentForInsert(data) { + let group = { + date_created: new Date() + }; + + group.name = parse.getString(data.name); + group.description = parse.getString(data.description); + + return group; + } + + getValidDocumentForUpdate(id, data) { + if (Object.keys(data).length === 0) { + return new Error('Required fields are missing'); + } + + let group = { + date_updated: new Date() + }; + + if (data.name !== undefined) { + group.name = parse.getString(data.name); + } + + if (data.description !== undefined) { + group.description = parse.getString(data.description); + } + + return group; + } + + changeProperties(item) { + if (item) { + item.id = item._id.toString(); + delete item._id; + } + + return item; + } +} + +export default new CustomerGroupsService(); diff --git a/src/api/server/services/customers/customers.js b/src/api/server/services/customers/customers.js new file mode 100755 index 0000000..d6648ae --- /dev/null +++ b/src/api/server/services/customers/customers.js @@ -0,0 +1,507 @@ +import { ObjectID } from 'mongodb'; +import { db } from '../../lib/mongo'; +import utils from '../../lib/utils'; +import parse from '../../lib/parse'; +import webhooks from '../../lib/webhooks'; +import CustomerGroupsService from './customerGroups'; + +class CustomersService { + constructor() {} + + getFilter(params = {}) { + // tag + // gender + // date_created_to + // date_created_from + // total_spent_to + // total_spent_from + // orders_count_to + // orders_count_from + + let filter = {}; + const id = parse.getObjectIDIfValid(params.id); + const group_id = parse.getObjectIDIfValid(params.group_id); + + if (id) { + filter._id = new ObjectID(id); + } + + if (group_id) { + filter.group_id = group_id; + } + + if (params.email) { + filter.email = params.email.toLowerCase(); + } + + if (params.search) { + filter['$or'] = [ + { email: new RegExp(params.search, 'i') }, + { mobile: new RegExp(params.search, 'i') }, + { $text: { $search: params.search } } + ]; + } + + return filter; + } + + getCustomers(params = {}) { + let filter = this.getFilter(params); + const limit = parse.getNumberIfPositive(params.limit) || 1000; + const offset = parse.getNumberIfPositive(params.offset) || 0; + + return Promise.all([ + CustomerGroupsService.getGroups(), + db + .collection('customers') + .find(filter) + .sort({ date_created: -1 }) + .skip(offset) + .limit(limit) + .toArray(), + db.collection('customers').countDocuments(filter) + ]).then(([customerGroups, customers, customersCount]) => { + const items = customers.map(customer => + this.changeProperties(customer, customerGroups) + ); + const result = { + total_count: customersCount, + has_more: offset + items.length < customersCount, + data: items + }; + return result; + }); + } + + getSingleCustomer(id) { + if (!ObjectID.isValid(id)) { + return Promise.reject('Invalid identifier'); + } + return this.getCustomers({ id: id }).then( + items => (items.data.length > 0 ? items.data[0] : {}) + ); + } + + async addCustomer(data) { + const customer = this.getValidDocumentForInsert(data); + + // is email unique + if (customer.email && customer.email.length > 0) { + const customerCount = await db + .collection('customers') + .count({ email: customer.email }); + if (customerCount > 0) { + return Promise.reject('Customer email must be unique'); + } + } + + const insertResponse = await db + .collection('customers') + .insertMany([customer]); + const newCustomerId = insertResponse.ops[0]._id.toString(); + const newCustomer = await this.getSingleCustomer(newCustomerId); + await webhooks.trigger({ + event: webhooks.events.CUSTOMER_CREATED, + payload: newCustomer + }); + return newCustomer; + } + + async updateCustomer(id, data) { + if (!ObjectID.isValid(id)) { + return Promise.reject('Invalid identifier'); + } + const customerObjectID = new ObjectID(id); + const customer = this.getValidDocumentForUpdate(id, data); + + // is email unique + if (customer.email && customer.email.length > 0) { + const customerCount = await db.collection('customers').count({ + _id: { + $ne: customerObjectID + }, + email: customer.email + }); + + if (customerCount > 0) { + return Promise.reject('Customer email must be unique'); + } + } + + await db.collection('customers').updateOne( + { + _id: customerObjectID + }, + { + $set: customer + } + ); + + const updatedCustomer = await this.getSingleCustomer(id); + await webhooks.trigger({ + event: webhooks.events.CUSTOMER_UPDATED, + payload: updatedCustomer + }); + return updatedCustomer; + } + + updateCustomerStatistics(customerId, totalSpent, ordersCount) { + if (!ObjectID.isValid(customerId)) { + return Promise.reject('Invalid identifier'); + } + const customerObjectID = new ObjectID(customerId); + const customerData = { + total_spent: totalSpent, + orders_count: ordersCount + }; + + return db + .collection('customers') + .updateOne({ _id: customerObjectID }, { $set: customerData }); + } + + async deleteCustomer(customerId) { + if (!ObjectID.isValid(customerId)) { + return Promise.reject('Invalid identifier'); + } + const customerObjectID = new ObjectID(customerId); + const customer = await this.getSingleCustomer(customerId); + const deleteResponse = await db + .collection('customers') + .deleteOne({ _id: customerObjectID }); + await webhooks.trigger({ + event: webhooks.events.CUSTOMER_DELETED, + payload: customer + }); + return deleteResponse.deletedCount > 0; + } + + getValidDocumentForInsert(data) { + let customer = { + date_created: new Date(), + date_updated: null, + total_spent: 0, + orders_count: 0 + }; + + customer.note = parse.getString(data.note); + customer.email = parse.getString(data.email).toLowerCase(); + customer.mobile = parse.getString(data.mobile).toLowerCase(); + customer.full_name = parse.getString(data.full_name); + customer.gender = parse.getString(data.gender).toLowerCase(); + customer.group_id = parse.getObjectIDIfValid(data.group_id); + customer.tags = parse.getArrayIfValid(data.tags) || []; + customer.social_accounts = + parse.getArrayIfValid(data.social_accounts) || []; + customer.birthdate = parse.getDateIfValid(data.birthdate); + customer.addresses = this.validateAddresses(data.addresses); + customer.browser = parse.getBrowser(data.browser); + + return customer; + } + + validateAddresses(addresses) { + if (addresses && addresses.length > 0) { + let validAddresses = addresses.map(addressItem => + parse.getCustomerAddress(addressItem) + ); + return validAddresses; + } else { + return []; + } + } + + getValidDocumentForUpdate(id, data) { + if (Object.keys(data).length === 0) { + return new Error('Required fields are missing'); + } + + let customer = { + date_updated: new Date() + }; + + if (data.note !== undefined) { + customer.note = parse.getString(data.note); + } + + if (data.email !== undefined) { + customer.email = parse.getString(data.email).toLowerCase(); + } + + if (data.mobile !== undefined) { + customer.mobile = parse.getString(data.mobile).toLowerCase(); + } + + if (data.full_name !== undefined) { + customer.full_name = parse.getString(data.full_name); + } + + if (data.gender !== undefined) { + customer.gender = parse.getString(data.gender); + } + + if (data.group_id !== undefined) { + customer.group_id = parse.getObjectIDIfValid(data.group_id); + } + + if (data.tags !== undefined) { + customer.tags = parse.getArrayIfValid(data.tags) || []; + } + + if (data.social_accounts !== undefined) { + customer.social_accounts = + parse.getArrayIfValid(data.social_accounts) || []; + } + + if (data.birthdate !== undefined) { + customer.birthdate = parse.getDateIfValid(data.birthdate); + } + + if (data.addresses !== undefined) { + customer.addresses = this.validateAddresses(data.addresses); + } + + if (data.browser !== undefined) { + customer.browser = parse.getBrowser(data.browser); + } + + return customer; + } + + changeProperties(customer, customerGroups) { + if (customer) { + customer.id = customer._id.toString(); + delete customer._id; + + const customerGroup = customer.group_id + ? customerGroups.find( + group => group.id === customer.group_id.toString() + ) + : null; + + customer.group_name = + customerGroup && customerGroup.name ? customerGroup.name : ''; + + if (customer.addresses && customer.addresses.length === 1) { + customer.billing = customer.shipping = customer.addresses[0]; + } else if (customer.addresses && customer.addresses.length > 1) { + let default_billing = customer.addresses.find( + address => address.default_billing + ); + let default_shipping = customer.addresses.find( + address => address.default_shipping + ); + customer.billing = default_billing + ? default_billing + : customer.addresses[0]; + customer.shipping = default_shipping + ? default_shipping + : customer.addresses[0]; + } else { + customer.billing = {}; + customer.shipping = {}; + } + } + + return customer; + } + + addAddress(customer_id, address) { + if (!ObjectID.isValid(customer_id)) { + return Promise.reject('Invalid identifier'); + } + let customerObjectID = new ObjectID(customer_id); + const validAddress = parse.getCustomerAddress(address); + + return db.collection('customers').updateOne( + { + _id: customerObjectID + }, + { + $push: { + addresses: validAddress + } + } + ); + } + + createObjectToUpdateAddressFields(address) { + let fields = {}; + + if (address.address1 !== undefined) { + fields['addresses.$.address1'] = parse.getString(address.address1); + } + + if (address.address2 !== undefined) { + fields['addresses.$.address2'] = parse.getString(address.address2); + } + + if (address.city !== undefined) { + fields['addresses.$.city'] = parse.getString(address.city); + } + + if (address.country !== undefined) { + fields['addresses.$.country'] = parse + .getString(address.country) + .toUpperCase(); + } + + if (address.state !== undefined) { + fields['addresses.$.state'] = parse.getString(address.state); + } + + if (address.phone !== undefined) { + fields['addresses.$.phone'] = parse.getString(address.phone); + } + + if (address.postal_code !== undefined) { + fields['addresses.$.postal_code'] = parse.getString(address.postal_code); + } + + if (address.full_name !== undefined) { + fields['addresses.$.full_name'] = parse.getString(address.full_name); + } + + if (address.company !== undefined) { + fields['addresses.$.company'] = parse.getString(address.company); + } + + if (address.tax_number !== undefined) { + fields['addresses.$.tax_number'] = parse.getString(address.tax_number); + } + + if (address.coordinates !== undefined) { + fields['addresses.$.coordinates'] = address.coordinates; + } + + if (address.details !== undefined) { + fields['addresses.$.details'] = address.details; + } + + if (address.default_billing !== undefined) { + fields['addresses.$.default_billing'] = parse.getBooleanIfValid( + address.default_billing, + false + ); + } + + if (address.default_shipping !== undefined) { + fields['addresses.$.default_shipping'] = parse.getBooleanIfValid( + address.default_shipping, + false + ); + } + + return fields; + } + + updateAddress(customer_id, address_id, data) { + if (!ObjectID.isValid(customer_id) || !ObjectID.isValid(address_id)) { + return Promise.reject('Invalid identifier'); + } + let customerObjectID = new ObjectID(customer_id); + let addressObjectID = new ObjectID(address_id); + const addressFields = this.createObjectToUpdateAddressFields(data); + + return db.collection('customers').updateOne( + { + _id: customerObjectID, + 'addresses.id': addressObjectID + }, + { $set: addressFields } + ); + } + + deleteAddress(customer_id, address_id) { + if (!ObjectID.isValid(customer_id) || !ObjectID.isValid(address_id)) { + return Promise.reject('Invalid identifier'); + } + let customerObjectID = new ObjectID(customer_id); + let addressObjectID = new ObjectID(address_id); + + return db.collection('customers').updateOne( + { + _id: customerObjectID + }, + { + $pull: { + addresses: { + id: addressObjectID + } + } + } + ); + } + + setDefaultBilling(customer_id, address_id) { + if (!ObjectID.isValid(customer_id) || !ObjectID.isValid(address_id)) { + return Promise.reject('Invalid identifier'); + } + let customerObjectID = new ObjectID(customer_id); + let addressObjectID = new ObjectID(address_id); + + return db + .collection('customers') + .updateOne( + { + _id: customerObjectID, + 'addresses.default_billing': true + }, + { + $set: { + 'addresses.$.default_billing': false + } + } + ) + .then(res => { + return db.collection('customers').updateOne( + { + _id: customerObjectID, + 'addresses.id': addressObjectID + }, + { + $set: { + 'addresses.$.default_billing': true + } + } + ); + }); + } + + setDefaultShipping(customer_id, address_id) { + if (!ObjectID.isValid(customer_id) || !ObjectID.isValid(address_id)) { + return Promise.reject('Invalid identifier'); + } + let customerObjectID = new ObjectID(customer_id); + let addressObjectID = new ObjectID(address_id); + + return db + .collection('customers') + .updateOne( + { + _id: customerObjectID, + 'addresses.default_shipping': true + }, + { + $set: { + 'addresses.$.default_shipping': false + } + } + ) + .then(res => { + return db.collection('customers').updateOne( + { + _id: customerObjectID, + 'addresses.id': addressObjectID + }, + { + $set: { + 'addresses.$.default_shipping': true + } + } + ); + }); + } +} + +export default new CustomersService(); diff --git a/src/api/server/services/files.js b/src/api/server/services/files.js new file mode 100755 index 0000000..34e6bb9 --- /dev/null +++ b/src/api/server/services/files.js @@ -0,0 +1,100 @@ +import path from 'path'; +import fs from 'fs'; +import url from 'url'; +import formidable from 'formidable'; +import utils from '../lib/utils'; +import settings from '../lib/settings'; + +const CONTENT_PATH = path.resolve(settings.filesUploadPath); + +class FilesService { + getFileData(fileName) { + const filePath = CONTENT_PATH + '/' + fileName; + const stats = fs.statSync(filePath); + if (stats.isFile()) { + return { + file: fileName, + size: stats.size, + modified: stats.mtime + }; + } else { + return null; + } + } + + getFilesData(files) { + return files + .map(fileName => this.getFileData(fileName)) + .filter(fileData => fileData !== null) + .sort((a, b) => a.modified - b.modified); + } + + getFiles() { + return new Promise((resolve, reject) => { + fs.readdir(CONTENT_PATH, (err, files) => { + if (err) { + reject(err); + } else { + const filesData = this.getFilesData(files); + resolve(filesData); + } + }); + }); + } + + deleteFile(fileName) { + return new Promise((resolve, reject) => { + const filePath = CONTENT_PATH + '/' + fileName; + if (fs.existsSync(filePath)) { + fs.unlink(filePath, err => { + resolve(); + }); + } else { + reject('File not found'); + } + }); + } + + uploadFile(req, res, next) { + const uploadDir = CONTENT_PATH; + + let form = new formidable.IncomingForm(), + file_name = null, + file_size = 0; + + form.uploadDir = uploadDir; + + form + .on('fileBegin', (name, file) => { + // Emitted whenever a field / value pair has been received. + file.name = utils.getCorrectFileName(file.name); + file.path = uploadDir + '/' + file.name; + }) + .on('file', function(name, file) { + // every time a file has been uploaded successfully, + file_name = file.name; + file_size = file.size; + }) + .on('error', err => { + res.status(500).send(this.getErrorMessage(err)); + }) + .on('end', () => { + //Emitted when the entire request has been received, and all contained files have finished flushing to disk. + if (file_name) { + res.send({ file: file_name, size: file_size }); + } else { + res + .status(400) + .send(this.getErrorMessage('Required fields are missing')); + } + }); + + form.parse(req); + } + + getErrorMessage(err) { + return { error: true, message: err.toString() }; + } +} + +export default new FilesService(); diff --git a/src/api/server/services/orders/orderAddress.js b/src/api/server/services/orders/orderAddress.js new file mode 100755 index 0000000..cd176b8 --- /dev/null +++ b/src/api/server/services/orders/orderAddress.js @@ -0,0 +1,76 @@ +import { ObjectID } from 'mongodb'; +import settings from '../../lib/settings'; +import { db } from '../../lib/mongo'; +import utils from '../../lib/utils'; +import parse from '../../lib/parse'; +import OrdersService from './orders'; + +class OrderAddressService { + constructor() {} + + updateBillingAddress(id, data) { + if (!ObjectID.isValid(id)) { + return Promise.reject('Invalid identifier'); + } + const orderObjectID = new ObjectID(id); + const billing_address = this.getValidDocumentForUpdate( + id, + data, + 'billing_address' + ); + + return db + .collection('orders') + .updateOne( + { + _id: orderObjectID + }, + { $set: billing_address } + ) + .then(res => OrdersService.getSingleOrder(id)); + } + + updateShippingAddress(id, data) { + if (!ObjectID.isValid(id)) { + return Promise.reject('Invalid identifier'); + } + const orderObjectID = new ObjectID(id); + const shipping_address = this.getValidDocumentForUpdate( + id, + data, + 'shipping_address' + ); + + return db + .collection('orders') + .updateOne( + { + _id: orderObjectID + }, + { $set: shipping_address } + ) + .then(res => OrdersService.getSingleOrder(id)); + } + + getValidDocumentForUpdate(id, data, addressTypeName) { + const keys = Object.keys(data); + if (keys.length === 0) { + return new Error('Required fields are missing'); + } + + let address = {}; + + keys.forEach(key => { + const value = data[key]; + if (key === 'coordinates' || key === 'details') { + address[`${addressTypeName}.${key}`] = value; + } else { + address[`${addressTypeName}.${key}`] = parse.getString(value); + } + }); + + return address; + } +} + +export default new OrderAddressService(); diff --git a/src/api/server/services/orders/orderDiscounts.js b/src/api/server/services/orders/orderDiscounts.js new file mode 100755 index 0000000..b357c3a --- /dev/null +++ b/src/api/server/services/orders/orderDiscounts.js @@ -0,0 +1,101 @@ +import { ObjectID } from 'mongodb'; +import settings from '../../lib/settings'; +import { db } from '../../lib/mongo'; +import utils from '../../lib/utils'; +import parse from '../../lib/parse'; +import OrdersService from './orders'; + +class OrdertDiscountsService { + constructor() {} + + addDiscount(order_id, data) { + if (!ObjectID.isValid(order_id)) { + return Promise.reject('Invalid identifier'); + } + let orderObjectID = new ObjectID(order_id); + const discount = this.getValidDocumentForInsert(data); + + return db.collection('orders').updateOne( + { + _id: orderObjectID + }, + { + $push: { + discounts: discount + } + } + ); + } + + updateDiscount(order_id, discount_id, data) { + if (!ObjectID.isValid(order_id) || !ObjectID.isValid(discount_id)) { + return Promise.reject('Invalid identifier'); + } + let orderObjectID = new ObjectID(order_id); + let discountObjectID = new ObjectID(discount_id); + const discount = this.getValidDocumentForUpdate(data); + + return db + .collection('orders') + .updateOne( + { + _id: orderObjectID, + 'discounts.id': discountObjectID + }, + { $set: discount } + ) + .then(res => OrdersService.getSingleOrder(order_id)); + } + + deleteDiscount(order_id, discount_id) { + if (!ObjectID.isValid(order_id) || !ObjectID.isValid(discount_id)) { + return Promise.reject('Invalid identifier'); + } + let orderObjectID = new ObjectID(order_id); + let discountObjectID = new ObjectID(discount_id); + + return db + .collection('orders') + .updateOne( + { + _id: orderObjectID + }, + { + $pull: { + discounts: { + id: discountObjectID + } + } + } + ) + .then(res => OrdersService.getSingleOrder(order_id)); + } + + getValidDocumentForInsert(data) { + return { + id: new ObjectID(), + name: parse.getString(data.name), + amount: parse.getNumberIfPositive(data.amount) + }; + } + + getValidDocumentForUpdate(data) { + if (Object.keys(data).length === 0) { + return new Error('Required fields are missing'); + } + + let discount = {}; + + if (data.variant_id !== undefined) { + discount['discounts.$.name'] = parse.getString(data.name); + } + + if (data.quantity !== undefined) { + discount['discounts.$.amount'] = parse.getNumberIfPositive(data.amount); + } + + return discount; + } +} + +export default new OrdertDiscountsService(); diff --git a/src/api/server/services/orders/orderItems.js b/src/api/server/services/orders/orderItems.js new file mode 100755 index 0000000..68a1a7d --- /dev/null +++ b/src/api/server/services/orders/orderItems.js @@ -0,0 +1,378 @@ +import { ObjectID } from 'mongodb'; +import settings from '../../lib/settings'; +import { db } from '../../lib/mongo'; +import utils from '../../lib/utils'; +import parse from '../../lib/parse'; +import OrdersService from './orders'; +import ProductsService from '../products/products'; +import ProductStockService from '../products/stock'; + +class OrderItemsService { + constructor() {} + + async addItem(order_id, data) { + if (!ObjectID.isValid(order_id)) { + return Promise.reject('Invalid identifier'); + } + + let newItem = this.getValidDocumentForInsert(data); + const orderItem = await this.getOrderItemIfExists( + order_id, + newItem.product_id, + newItem.variant_id + ); + + if (orderItem) { + await this.updateItemQuantityIfAvailable(order_id, orderItem, newItem); + } else { + await this.addNewItem(order_id, newItem); + } + + return OrdersService.getSingleOrder(order_id); + } + + async updateItemQuantityIfAvailable(order_id, orderItem, newItem) { + const quantityNeeded = orderItem.quantity + newItem.quantity; + const availableQuantity = await this.getAvailableProductQuantity( + newItem.product_id, + newItem.variant_id, + quantityNeeded + ); + + if (availableQuantity > 0) { + await this.updateItem(order_id, orderItem.id, { + quantity: availableQuantity + }); + } + } + + async addNewItem(order_id, newItem) { + const orderObjectID = new ObjectID(order_id); + const availableQuantity = await this.getAvailableProductQuantity( + newItem.product_id, + newItem.variant_id, + newItem.quantity + ); + + if (availableQuantity > 0) { + newItem.quantity = availableQuantity; + await db.collection('orders').updateOne( + { + _id: orderObjectID + }, + { + $push: { + items: newItem + } + } + ); + + await this.calculateAndUpdateItem(order_id, newItem.id); + await ProductStockService.handleAddOrderItem(order_id, newItem.id); + } + } + + async getAvailableProductQuantity(product_id, variant_id, quantityNeeded) { + const product = await ProductsService.getSingleProduct( + product_id.toString() + ); + + if (!product) { + return 0; + } else if (product.discontinued) { + return 0; + } else if (product.stock_backorder) { + return quantityNeeded; + } else if (product.variable && variant_id) { + const variant = this.getVariantFromProduct(product, variant_id); + if (variant) { + return variant.stock_quantity >= quantityNeeded + ? quantityNeeded + : variant.stock_quantity; + } else { + return 0; + } + } else { + return product.stock_quantity >= quantityNeeded + ? quantityNeeded + : product.stock_quantity; + } + } + + async getOrderItemIfExists(order_id, product_id, variant_id) { + let orderObjectID = new ObjectID(order_id); + const order = await db.collection('orders').findOne( + { + _id: orderObjectID + }, + { + items: 1 + } + ); + + if (order && order.items && order.items.length > 0) { + return order.items.find( + item => + item.product_id.toString() === product_id.toString() && + (item.variant_id || '').toString() === (variant_id || '').toString() + ); + } else { + return null; + } + } + + async updateItem(order_id, item_id, data) { + if (!ObjectID.isValid(order_id) || !ObjectID.isValid(item_id)) { + return Promise.reject('Invalid identifier'); + } + let orderObjectID = new ObjectID(order_id); + let itemObjectID = new ObjectID(item_id); + const item = this.getValidDocumentForUpdate(data); + + if (parse.getNumberIfPositive(data.quantity) === 0) { + // delete item + return this.deleteItem(order_id, item_id); + } else { + // update + await ProductStockService.handleDeleteOrderItem(order_id, item_id); + await db.collection('orders').updateOne( + { + _id: orderObjectID, + 'items.id': itemObjectID + }, + { + $set: item + } + ); + + await this.calculateAndUpdateItem(order_id, item_id); + await ProductStockService.handleAddOrderItem(order_id, item_id); + return OrdersService.getSingleOrder(order_id); + } + } + + getVariantFromProduct(product, variantId) { + if (product.variants && product.variants.length > 0) { + return product.variants.find( + variant => variant.id.toString() === variantId.toString() + ); + } else { + return null; + } + } + + getOptionFromProduct(product, optionId) { + if (product.options && product.options.length > 0) { + return product.options.find( + item => item.id.toString() === optionId.toString() + ); + } else { + return null; + } + } + + getOptionValueFromProduct(product, optionId, valueId) { + const option = this.getOptionFromProduct(product, optionId); + if (option && option.values && option.values.length > 0) { + return option.values.find( + item => item.id.toString() === valueId.toString() + ); + } else { + return null; + } + } + + getOptionNameFromProduct(product, optionId) { + const option = this.getOptionFromProduct(product, optionId); + return option ? option.name : null; + } + + getOptionValueNameFromProduct(product, optionId, valueId) { + const value = this.getOptionValueFromProduct(product, optionId, valueId); + return value ? value.name : null; + } + + getVariantNameFromProduct(product, variantId) { + const variant = this.getVariantFromProduct(product, variantId); + if (variant) { + let optionNames = []; + for (const option of variant.options) { + const optionName = this.getOptionNameFromProduct( + product, + option.option_id + ); + const optionValueName = this.getOptionValueNameFromProduct( + product, + option.option_id, + option.value_id + ); + optionNames.push(`${optionName}: ${optionValueName}`); + } + return optionNames.join(', '); + } + + return null; + } + + async calculateAndUpdateItem(orderId, itemId) { + // TODO: tax_total, discount_total + + const orderObjectID = new ObjectID(orderId); + const itemObjectID = new ObjectID(itemId); + + const order = await OrdersService.getSingleOrder(orderId); + + if (order && order.items && order.items.length > 0) { + const item = order.items.find(i => i.id.toString() === itemId.toString()); + if (item) { + const itemData = await this.getCalculatedData(item); + await db.collection('orders').updateOne( + { + _id: orderObjectID, + 'items.id': itemObjectID + }, + { + $set: itemData + } + ); + } + } + } + + async getCalculatedData(item) { + const product = await ProductsService.getSingleProduct( + item.product_id.toString() + ); + + if (item.custom_price && item.custom_price > 0) { + // product with custom price - can set on client side + return { + 'items.$.sku': product.sku, + 'items.$.name': product.name, + 'items.$.variant_name': item.custom_note || '', + 'items.$.price': item.custom_price, + 'items.$.tax_class': product.tax_class, + 'items.$.tax_total': 0, + 'items.$.weight': product.weight || 0, + 'items.$.discount_total': 0, + 'items.$.price_total': item.custom_price * item.quantity + }; + } else if (item.variant_id) { + // product with variant + const variant = this.getVariantFromProduct(product, item.variant_id); + const variantName = this.getVariantNameFromProduct( + product, + item.variant_id + ); + const variantPrice = + variant.price && variant.price > 0 ? variant.price : product.price; + + if (variant) { + return { + 'items.$.sku': variant.sku, + 'items.$.name': product.name, + 'items.$.variant_name': variantName, + 'items.$.price': variantPrice, + 'items.$.tax_class': product.tax_class, + 'items.$.tax_total': 0, + 'items.$.weight': variant.weight || 0, + 'items.$.discount_total': 0, + 'items.$.price_total': variantPrice * item.quantity + }; + } else { + // variant not exists + return null; + } + } else { + // normal product + return { + 'items.$.sku': product.sku, + 'items.$.name': product.name, + 'items.$.variant_name': '', + 'items.$.price': product.price, + 'items.$.tax_class': product.tax_class, + 'items.$.tax_total': 0, + 'items.$.weight': product.weight || 0, + 'items.$.discount_total': 0, + 'items.$.price_total': product.price * item.quantity + }; + } + } + + async calculateAndUpdateAllItems(order_id) { + const order = await OrdersService.getSingleOrder(order_id); + + if (order && order.items) { + for (const item of order.items) { + await this.calculateAndUpdateItem(order_id, item.id); + } + return OrdersService.getSingleOrder(order_id); + } else { + // order.items is empty + return null; + } + } + + async deleteItem(order_id, item_id) { + if (!ObjectID.isValid(order_id) || !ObjectID.isValid(item_id)) { + return Promise.reject('Invalid identifier'); + } + let orderObjectID = new ObjectID(order_id); + let itemObjectID = new ObjectID(item_id); + + await ProductStockService.handleDeleteOrderItem(order_id, item_id); + await db.collection('orders').updateOne( + { + _id: orderObjectID + }, + { + $pull: { + items: { + id: itemObjectID + } + } + } + ); + + return OrdersService.getSingleOrder(order_id); + } + + getValidDocumentForInsert(data) { + let item = { + id: new ObjectID(), + product_id: parse.getObjectIDIfValid(data.product_id), + variant_id: parse.getObjectIDIfValid(data.variant_id), + quantity: parse.getNumberIfPositive(data.quantity) || 1 + }; + + if (data.custom_price) { + item.custom_price = parse.getNumberIfPositive(data.custom_price); + } + + if (data.custom_note) { + item.custom_note = parse.getString(data.custom_note); + } + + return item; + } + + getValidDocumentForUpdate(data) { + if (Object.keys(data).length === 0) { + return new Error('Required fields are missing'); + } + + let item = {}; + + if (data.variant_id !== undefined) { + item['items.$.variant_id'] = parse.getObjectIDIfValid(data.variant_id); + } + + if (data.quantity !== undefined) { + item['items.$.quantity'] = parse.getNumberIfPositive(data.quantity); + } + + return item; + } +} + +export default new OrderItemsService(); diff --git a/src/api/server/services/orders/orderStatuses.js b/src/api/server/services/orders/orderStatuses.js new file mode 100755 index 0000000..ef0d4b7 --- /dev/null +++ b/src/api/server/services/orders/orderStatuses.js @@ -0,0 +1,123 @@ +import { ObjectID } from 'mongodb'; +import { db } from '../../lib/mongo'; +import utils from '../../lib/utils'; +import parse from '../../lib/parse'; + +class OrderStatusesService { + constructor() {} + + getStatuses(params = {}) { + let filter = {}; + const id = parse.getObjectIDIfValid(params.id); + if (id) { + filter._id = new ObjectID(id); + } + + return db + .collection('orderStatuses') + .find(filter) + .toArray() + .then(items => items.map(item => this.changeProperties(item))); + } + + getSingleStatus(id) { + if (!ObjectID.isValid(id)) { + return Promise.reject('Invalid identifier'); + } + return this.getStatuses({ id: id }).then(statuses => { + return statuses.length > 0 ? statuses[0] : null; + }); + } + + addStatus(data) { + const status = this.getValidDocumentForInsert(data); + return db + .collection('orderStatuses') + .insertMany([status]) + .then(res => this.getSingleStatus(res.ops[0]._id.toString())); + } + + updateStatus(id, data) { + if (!ObjectID.isValid(id)) { + return Promise.reject('Invalid identifier'); + } + const statusObjectID = new ObjectID(id); + const status = this.getValidDocumentForUpdate(id, data); + + return db + .collection('orderStatuses') + .updateOne( + { + _id: statusObjectID + }, + { $set: status } + ) + .then(res => this.getSingleStatus(id)); + } + + deleteStatus(id) { + if (!ObjectID.isValid(id)) { + return Promise.reject('Invalid identifier'); + } + const statusObjectID = new ObjectID(id); + return db + .collection('orderStatuses') + .deleteOne({ _id: statusObjectID }) + .then(deleteResponse => { + return deleteResponse.deletedCount > 0; + }); + } + + getValidDocumentForInsert(data) { + let status = {}; + + status.name = parse.getString(data.name); + status.description = parse.getString(data.description); + status.color = parse.getString(data.color); + status.bgcolor = parse.getString(data.bgcolor); + status.is_public = parse.getBooleanIfValid(data.is_public, false); + + return status; + } + + getValidDocumentForUpdate(id, data) { + if (Object.keys(data).length === 0) { + return new Error('Required fields are missing'); + } + + let status = {}; + + if (data.name !== undefined) { + status.name = parse.getString(data.name); + } + + if (data.description !== undefined) { + status.description = parse.getString(data.description); + } + + if (data.color !== undefined) { + status.color = parse.getString(data.color); + } + + if (data.bgcolor !== undefined) { + status.bgcolor = parse.getString(data.bgcolor); + } + + if (data.is_public !== undefined) { + status.is_public = parse.getBooleanIfValid(data.is_public, false); + } + + return status; + } + + changeProperties(item) { + if (item) { + item.id = item._id.toString(); + delete item._id; + } + + return item; + } +} + +export default new OrderStatusesService(); diff --git a/src/api/server/services/orders/orderTransactions.js b/src/api/server/services/orders/orderTransactions.js new file mode 100755 index 0000000..4016340 --- /dev/null +++ b/src/api/server/services/orders/orderTransactions.js @@ -0,0 +1,143 @@ +import { ObjectID } from 'mongodb'; +import { db } from '../../lib/mongo'; +import utils from '../../lib/utils'; +import parse from '../../lib/parse'; +import webhooks from '../../lib/webhooks'; +import OrdersService from './orders'; + +class OrdertTansactionsService { + constructor() {} + + async addTransaction(order_id, data) { + if (!ObjectID.isValid(order_id)) { + return Promise.reject('Invalid identifier'); + } + let orderObjectID = new ObjectID(order_id); + const transaction = this.getValidDocumentForInsert(data); + + await db.collection('orders').updateOne( + { + _id: orderObjectID + }, + { + $push: { + transactions: transaction + } + } + ); + + const order = await OrdersService.getSingleOrder(order_id); + await webhooks.trigger({ + event: webhooks.events.TRANSACTION_CREATED, + payload: order + }); + return order; + } + + async updateTransaction(order_id, transaction_id, data) { + if (!ObjectID.isValid(order_id) || !ObjectID.isValid(transaction_id)) { + return Promise.reject('Invalid identifier'); + } + let orderObjectID = new ObjectID(order_id); + let transactionObjectID = new ObjectID(transaction_id); + const transaction = this.getValidDocumentForUpdate(data); + + await db.collection('orders').updateOne( + { + _id: orderObjectID, + 'transactions.id': transactionObjectID + }, + { + $set: transaction + } + ); + + const order = await OrdersService.getSingleOrder(order_id); + await webhooks.trigger({ + event: webhooks.events.TRANSACTION_UPDATED, + payload: order + }); + return order; + } + + async deleteTransaction(order_id, transaction_id) { + if (!ObjectID.isValid(order_id) || !ObjectID.isValid(transaction_id)) { + return Promise.reject('Invalid identifier'); + } + let orderObjectID = new ObjectID(order_id); + let transactionObjectID = new ObjectID(transaction_id); + + await db.collection('orders').updateOne( + { + _id: orderObjectID + }, + { + $pull: { + transactions: { + id: transactionObjectID + } + } + } + ); + + const order = await OrdersService.getSingleOrder(order_id); + await webhooks.trigger({ + event: webhooks.events.TRANSACTION_DELETED, + payload: order + }); + return order; + } + + getValidDocumentForInsert(data) { + return { + id: new ObjectID(), + transaction_id: parse.getString(data.transaction_id), + amount: parse.getNumberIfPositive(data.amount) || 0, + currency: parse.getString(data.currency), + status: parse.getString(data.status), + details: parse.getString(data.details), + success: parse.getBooleanIfValid(data.success) + }; + } + + getValidDocumentForUpdate(data) { + if (Object.keys(data).length === 0) { + return new Error('Required fields are missing'); + } + + let transaction = {}; + + if (data.transaction_id !== undefined) { + transaction['transactions.$.transaction_id'] = parse.getString( + data.transaction_id + ); + } + + if (data.amount !== undefined) { + transaction['transactions.$.amount'] = + parse.getNumberIfPositive(data.amount) || 0; + } + + if (data.currency !== undefined) { + transaction['transactions.$.currency'] = parse.getString(data.currency); + } + + if (data.status !== undefined) { + transaction['transactions.$.status'] = parse.getString(data.status); + } + + if (data.details !== undefined) { + transaction['transactions.$.details'] = parse.getString(data.details); + } + + if (data.success !== undefined) { + transaction['transactions.$.success'] = parse.getBooleanIfValid( + data.success + ); + } + + return transaction; + } +} + +export default new OrdertTansactionsService(); diff --git a/src/api/server/services/orders/orders.js b/src/api/server/services/orders/orders.js new file mode 100755 index 0000000..616cd0c --- /dev/null +++ b/src/api/server/services/orders/orders.js @@ -0,0 +1,767 @@ +import { ObjectID } from 'mongodb'; +import winston from 'winston'; +import handlebars from 'handlebars'; +import settings from '../../lib/settings'; +import { db } from '../../lib/mongo'; +import utils from '../../lib/utils'; +import parse from '../../lib/parse'; +import webhooks from '../../lib/webhooks'; +import dashboardWebSocket from '../../lib/dashboardWebSocket'; +import mailer from '../../lib/mailer'; +import ProductsService from '../products/products'; +import CustomersService from '../customers/customers'; +import OrderStatusesService from './orderStatuses'; +import PaymentMethodsLightService from './paymentMethodsLight'; +import ShippingMethodsLightService from './shippingMethodsLight'; +import EmailTemplatesService from '../settings/emailTemplates'; +import ProductStockService from '../products/stock'; +import SettingsService from '../settings/settings'; +import PaymentGateways from '../../paymentGateways'; + +class OrdersService { + constructor() {} + + getFilter(params = {}) { + // TODO: sort, coupon, tag, channel + let filter = {}; + const id = parse.getObjectIDIfValid(params.id); + const status_id = parse.getObjectIDIfValid(params.status_id); + const customer_id = parse.getObjectIDIfValid(params.customer_id); + const payment_method_id = parse.getObjectIDIfValid( + params.payment_method_id + ); + const shipping_method_id = parse.getObjectIDIfValid( + params.shipping_method_id + ); + const closed = parse.getBooleanIfValid(params.closed); + const cancelled = parse.getBooleanIfValid(params.cancelled); + const delivered = parse.getBooleanIfValid(params.delivered); + const paid = parse.getBooleanIfValid(params.paid); + const draft = parse.getBooleanIfValid(params.draft); + const hold = parse.getBooleanIfValid(params.hold); + const grand_total_min = parse.getNumberIfPositive(params.grand_total_min); + const grand_total_max = parse.getNumberIfPositive(params.grand_total_max); + const date_placed_min = parse.getDateIfValid(params.date_placed_min); + const date_placed_max = parse.getDateIfValid(params.date_placed_max); + const date_closed_min = parse.getDateIfValid(params.date_closed_min); + const date_closed_max = parse.getDateIfValid(params.date_closed_max); + + if (id) { + filter._id = new ObjectID(id); + } + + if (status_id) { + filter.status_id = status_id; + } + + if (customer_id) { + filter.customer_id = customer_id; + } + + if (payment_method_id) { + filter.payment_method_id = payment_method_id; + } + + if (shipping_method_id) { + filter.shipping_method_id = shipping_method_id; + } + + if (params.number) { + filter.number = params.number; + } + + if (closed !== null) { + filter.closed = closed; + } + + if (cancelled !== null) { + filter.cancelled = cancelled; + } + + if (delivered !== null) { + filter.delivered = delivered; + } + + if (paid !== null) { + filter.paid = paid; + } + + if (draft !== null) { + filter.draft = draft; + } + + if (hold !== null) { + filter.hold = hold; + } + + if (grand_total_min || grand_total_max) { + filter.grand_total = {}; + if (grand_total_min) { + filter.grand_total['$gte'] = grand_total_min; + } + if (grand_total_max) { + filter.grand_total['$lte'] = grand_total_max; + } + } + + if (date_placed_min || date_placed_max) { + filter.date_placed = {}; + if (date_placed_min) { + filter.date_placed['$gte'] = date_placed_min; + } + if (date_placed_max) { + filter.date_placed['$lte'] = date_placed_max; + } + } + + if (date_closed_min || date_closed_max) { + filter.date_closed = {}; + if (date_closed_min) { + filter.date_closed['$gte'] = date_closed_min; + } + if (date_closed_max) { + filter.date_closed['$lte'] = date_closed_max; + } + } + + if (params.search) { + let alternativeSearch = []; + + const searchAsNumber = parse.getNumberIfPositive(params.search); + if (searchAsNumber) { + alternativeSearch.push({ number: searchAsNumber }); + } + + alternativeSearch.push({ email: new RegExp(params.search, 'i') }); + alternativeSearch.push({ mobile: new RegExp(params.search, 'i') }); + alternativeSearch.push({ $text: { $search: params.search } }); + + filter['$or'] = alternativeSearch; + } + + return filter; + } + + getOrders(params) { + let filter = this.getFilter(params); + const limit = parse.getNumberIfPositive(params.limit) || 1000; + const offset = parse.getNumberIfPositive(params.offset) || 0; + + return Promise.all([ + db + .collection('orders') + .find(filter) + .sort({ date_placed: -1, date_created: -1 }) + .skip(offset) + .limit(limit) + .toArray(), + db.collection('orders').countDocuments(filter), + OrderStatusesService.getStatuses(), + ShippingMethodsLightService.getMethods(), + PaymentMethodsLightService.getMethods() + ]).then( + ([ + orders, + ordersCount, + orderStatuses, + shippingMethods, + paymentMethods + ]) => { + const items = orders.map(order => + this.changeProperties( + order, + orderStatuses, + shippingMethods, + paymentMethods + ) + ); + const result = { + total_count: ordersCount, + has_more: offset + items.length < ordersCount, + data: items + }; + return result; + } + ); + } + + getSingleOrder(id) { + if (!ObjectID.isValid(id)) { + return Promise.reject('Invalid identifier'); + } + return this.getOrders({ id: id }).then( + items => (items.data.length > 0 ? items.data[0] : {}) + ); + } + + getOrCreateCustomer(orderId) { + return this.getSingleOrder(orderId).then(order => { + if (!order.customer_id && order.email) { + // find customer by email + return CustomersService.getCustomers({ email: order.email }).then( + customers => { + const customerExists = + customers && customers.data && customers.data.length > 0; + + if (customerExists) { + // if customer exists - set new customer_id + return customers.data[0].id; + } else { + // if customer not exists - create new customer and set new customer_id + let addresses = []; + if (order.shipping_address) { + addresses.push(order.shipping_address); + } + + let customerrFullName = + order.shipping_address && order.shipping_address.full_name + ? order.shipping_address.full_name + : ''; + + return CustomersService.addCustomer({ + email: order.email, + full_name: customerrFullName, + mobile: order.mobile, + browser: order.browser, + addresses: addresses + }).then(customer => { + return customer.id; + }); + } + } + ); + } else { + return order.customer_id; + } + }); + } + + async addOrder(data) { + const order = await this.getValidDocumentForInsert(data); + const insertResponse = await db.collection('orders').insertMany([order]); + const newOrderId = insertResponse.ops[0]._id.toString(); + const newOrder = await this.getSingleOrder(newOrderId); + return newOrder; + } + + async updateOrder(id, data) { + if (!ObjectID.isValid(id)) { + return Promise.reject('Invalid identifier'); + } + const orderObjectID = new ObjectID(id); + const orderData = await this.getValidDocumentForUpdate(id, data); + const updateResponse = await db + .collection('orders') + .updateOne({ _id: orderObjectID }, { $set: orderData }); + const updatedOrder = await this.getSingleOrder(id); + if (updatedOrder.draft === false) { + await webhooks.trigger({ + event: webhooks.events.ORDER_UPDATED, + payload: updatedOrder + }); + } + await this.updateCustomerStatistics(updatedOrder.customer_id); + return updatedOrder; + } + + async deleteOrder(orderId) { + if (!ObjectID.isValid(orderId)) { + return Promise.reject('Invalid identifier'); + } + const orderObjectID = new ObjectID(orderId); + const order = await this.getSingleOrder(orderId); + await webhooks.trigger({ + event: webhooks.events.ORDER_DELETED, + payload: order + }); + const deleteResponse = await db + .collection('orders') + .deleteOne({ _id: orderObjectID }); + return deleteResponse.deletedCount > 0; + } + + parseDiscountItem(discount) { + return discount + ? { + id: new ObjectID(), + name: parse.getString(discount.name), + amount: parse.getNumberIfPositive(discount.amount) + } + : null; + } + + parseProductItem(item) { + return item + ? { + id: new ObjectID(), + product_id: parse.getObjectIDIfValid(item.product_id), + variant_id: parse.getObjectIDIfValid(item.variant_id), + quantity: parse.getNumberIfPositive(item.quantity) + // "sku":"", + // "name":"", + // "variant_name":"", + // "price":"", + // "tax_class":"", + // "tax_total":"", + // "weight":"", + // "discount_total":"", + // "price_total":"", //price * quantity + } + : null; + } + + parseTransactionItem(transaction) { + return transaction + ? { + id: new ObjectID(), + transaction_id: parse.getString(transaction.transaction_id), + amount: parse.getNumberIfPositive(transaction.amount), + currency: parse.getString(transaction.currency), + status: parse.getString(transaction.status), + details: transaction.details, + success: parse.getBooleanIfValid(transaction.success), + date_created: new Date(), + date_updated: null + } + : null; + } + + getValidDocumentForInsert(data) { + return db + .collection('orders') + .find({}, { number: 1 }) + .sort({ number: -1 }) + .limit(1) + .toArray() + .then(items => { + let orderNumber = settings.orderStartNumber; + if (items && items.length > 0) { + orderNumber = items[0].number + 1; + } + + let order = { + date_created: new Date(), + date_placed: null, + date_updated: null, + date_closed: null, + date_paid: null, + date_cancelled: null, + number: orderNumber, + shipping_status: '' + // 'weight_total': 0, + // 'discount_total': 0, //sum(items.discount_total)+sum(discounts.amount) + // 'tax_included_total': 0, //if(item_tax_included, 0, item_tax) + if(shipment_tax_included, 0, shipping_tax) + // 'tax_total': 0, //item_tax + shipping_tax + // 'subtotal': 0, //sum(items.price_total) + // 'shipping_total': 0, //shipping_price-shipping_discount + // 'grand_total': 0 //subtotal + shipping_total + tax_included_total - (discount_total) + }; + + order.items = + data.items && data.items.length > 0 + ? data.items.map(item => this.parseProductItem(item)) + : []; + order.transactions = + data.transactions && data.transactions.length > 0 + ? data.transactions.map(transaction => + this.parseTransactionItem(transaction) + ) + : []; + order.discounts = + data.discounts && data.discounts.length > 0 + ? data.discounts.map(discount => this.parseDiscountItem(discount)) + : []; + + order.billing_address = parse.getOrderAddress(data.billing_address); + order.shipping_address = parse.getOrderAddress(data.shipping_address); + + order.item_tax = parse.getNumberIfPositive(data.item_tax) || 0; + order.shipping_tax = parse.getNumberIfPositive(data.shipping_tax) || 0; + order.shipping_discount = + parse.getNumberIfPositive(data.shipping_discount) || 0; + order.shipping_price = + parse.getNumberIfPositive(data.shipping_price) || 0; + + order.item_tax_included = parse.getBooleanIfValid( + data.item_tax_included, + true + ); + order.shipping_tax_included = parse.getBooleanIfValid( + data.shipping_tax_included, + true + ); + order.closed = parse.getBooleanIfValid(data.closed, false); + order.cancelled = parse.getBooleanIfValid(data.cancelled, false); + order.delivered = parse.getBooleanIfValid(data.delivered, false); + order.paid = parse.getBooleanIfValid(data.paid, false); + order.hold = parse.getBooleanIfValid(data.hold, false); + order.draft = parse.getBooleanIfValid(data.draft, true); + + order.email = parse.getString(data.email).toLowerCase(); + order.mobile = parse.getString(data.mobile).toLowerCase(); + order.referrer_url = parse.getString(data.referrer_url).toLowerCase(); + order.landing_url = parse.getString(data.landing_url).toLowerCase(); + order.channel = parse.getString(data.channel); + order.note = parse.getString(data.note); + order.comments = parse.getString(data.comments); + order.coupon = parse.getString(data.coupon); + order.tracking_number = parse.getString(data.tracking_number); + + order.customer_id = parse.getObjectIDIfValid(data.customer_id); + order.status_id = parse.getObjectIDIfValid(data.status_id); + order.payment_method_id = parse.getObjectIDIfValid( + data.payment_method_id + ); + order.shipping_method_id = parse.getObjectIDIfValid( + data.shipping_method_id + ); + + order.tags = parse.getArrayIfValid(data.tags) || []; + order.browser = parse.getBrowser(data.browser); + + return order; + }); + } + + getValidDocumentForUpdate(id, data) { + return new Promise((resolve, reject) => { + if (Object.keys(data).length === 0) { + reject(new Error('Required fields are missing')); + } + + let order = { + date_updated: new Date() + }; + + if (data.payment_token !== undefined) { + order.payment_token = parse.getString(data.payment_token); + } + + if (data.item_tax !== undefined) { + order.item_tax = parse.getNumberIfPositive(data.item_tax) || 0; + } + if (data.shipping_tax !== undefined) { + order.shipping_tax = parse.getNumberIfPositive(data.shipping_tax) || 0; + } + if (data.shipping_discount !== undefined) { + order.shipping_discount = + parse.getNumberIfPositive(data.shipping_discount) || 0; + } + if (data.shipping_price !== undefined) { + order.shipping_price = + parse.getNumberIfPositive(data.shipping_price) || 0; + } + if (data.item_tax_included !== undefined) { + order.item_tax_included = parse.getBooleanIfValid( + data.item_tax_included, + true + ); + } + if (data.shipping_tax_included !== undefined) { + order.shipping_tax_included = parse.getBooleanIfValid( + data.shipping_tax_included, + true + ); + } + if (data.closed !== undefined) { + order.closed = parse.getBooleanIfValid(data.closed, false); + } + if (data.cancelled !== undefined) { + order.cancelled = parse.getBooleanIfValid(data.cancelled, false); + } + if (data.delivered !== undefined) { + order.delivered = parse.getBooleanIfValid(data.delivered, false); + } + if (data.paid !== undefined) { + order.paid = parse.getBooleanIfValid(data.paid, false); + } + if (data.hold !== undefined) { + order.hold = parse.getBooleanIfValid(data.hold, false); + } + if (data.draft !== undefined) { + order.draft = parse.getBooleanIfValid(data.draft, true); + } + if (data.email !== undefined) { + order.email = parse.getString(data.email).toLowerCase(); + } + if (data.mobile !== undefined) { + order.mobile = parse.getString(data.mobile).toLowerCase(); + } + if (data.referrer_url !== undefined) { + order.referrer_url = parse.getString(data.referrer_url).toLowerCase(); + } + if (data.landing_url !== undefined) { + order.landing_url = parse.getString(data.landing_url).toLowerCase(); + } + if (data.channel !== undefined) { + order.channel = parse.getString(data.channel); + } + if (data.note !== undefined) { + order.note = parse.getString(data.note); + } + if (data.comments !== undefined) { + order.comments = parse.getString(data.comments); + } + if (data.coupon !== undefined) { + order.coupon = parse.getString(data.coupon); + } + if (data.tracking_number !== undefined) { + order.tracking_number = parse.getString(data.tracking_number); + } + if (data.shipping_status !== undefined) { + order.shipping_status = parse.getString(data.shipping_status); + } + if (data.customer_id !== undefined) { + order.customer_id = parse.getObjectIDIfValid(data.customer_id); + } + if (data.status_id !== undefined) { + order.status_id = parse.getObjectIDIfValid(data.status_id); + } + if (data.payment_method_id !== undefined) { + order.payment_method_id = parse.getObjectIDIfValid( + data.payment_method_id + ); + } + if (data.shipping_method_id !== undefined) { + order.shipping_method_id = parse.getObjectIDIfValid( + data.shipping_method_id + ); + } + if (data.tags !== undefined) { + order.tags = parse.getArrayIfValid(data.tags) || []; + } + if (data.browser !== undefined) { + order.browser = parse.getBrowser(data.browser); + } + if (data.date_placed !== undefined) { + order.date_placed = parse.getDateIfValid(data.date_placed); + } + if (data.date_paid !== undefined) { + order.date_paid = parse.getDateIfValid(data.date_paid); + } + + if (order.shipping_method_id && !order.shipping_price) { + ShippingMethodsLightService.getMethodPrice( + order.shipping_method_id + ).then(shippingPrice => { + order.shipping_price = shippingPrice; + resolve(order); + }); + } else { + resolve(order); + } + }); + } + + changeProperties(order, orderStatuses, shippingMethods, paymentMethods) { + if (order) { + order.id = order._id.toString(); + delete order._id; + + let orderStatus = + order.status_id && orderStatuses.length > 0 + ? orderStatuses.find( + i => i.id.toString() === order.status_id.toString() + ) + : null; + let orderShippingMethod = + order.shipping_method_id && shippingMethods.length > 0 + ? shippingMethods.find( + i => i.id.toString() === order.shipping_method_id.toString() + ) + : null; + let orderPaymentMethod = + order.payment_method_id && paymentMethods.length > 0 + ? paymentMethods.find( + i => i.id.toString() === order.payment_method_id.toString() + ) + : null; + + order.status = orderStatus ? orderStatus.name : ''; + order.shipping_method = orderShippingMethod + ? orderShippingMethod.name + : ''; + order.payment_method = orderPaymentMethod ? orderPaymentMethod.name : ''; + order.payment_method_gateway = orderPaymentMethod + ? orderPaymentMethod.gateway + : ''; + + let sum_items_weight = 0; + let sum_items_price_total = 0; + let sum_items_discount_total = 0; + let sum_discounts_amount = 0; + let tax_included_total = + (order.item_tax_included ? 0 : order.item_tax) + + (order.shipping_tax_included ? 0 : order.shipping_tax); + + if (order.items && order.items.length > 0) { + order.items.forEach(item => { + let item_weight = item.weight * item.quantity; + if (item_weight > 0) { + sum_items_weight += item_weight; + } + }); + + order.items.forEach(item => { + if (item.price_total > 0) { + sum_items_price_total += item.price_total; + } + }); + + order.items.forEach(item => { + if (item.discount_total > 0) { + sum_items_discount_total += item.discount_total; + } + }); + } + + if (order.discounts && order.discounts.length > 0) { + order.items.forEach(item => { + if (item.amount > 0) { + sum_discounts_amount += item.amount; + } + }); + } + + let tax_total = order.item_tax + order.shipping_tax; + let shipping_total = order.shipping_price - order.shipping_discount; + let discount_total = sum_items_discount_total + sum_discounts_amount; + let grand_total = + sum_items_price_total + + shipping_total + + tax_included_total - + discount_total; + + order.weight_total = sum_items_weight; + order.discount_total = discount_total; //sum(items.discount_total)+sum(discounts.amount) + order.subtotal = sum_items_price_total; //sum(items.price_total) + order.tax_included_total = tax_included_total; //if(item_tax_included, 0, item_tax) + if(shipment_tax_included, 0, shipping_tax) + order.tax_total = tax_total; //item_tax + shipping_tax + order.shipping_total = shipping_total; //shipping_price-shipping_discount + order.grand_total = grand_total; //subtotal + shipping_total + tax_included_total - (discount_total) + } + + return order; + } + + getEmailSubject(emailTemplate, order) { + const subjectTemplate = handlebars.compile(emailTemplate.subject); + return subjectTemplate(order); + } + + getEmailBody(emailTemplate, order) { + const bodyTemplate = handlebars.compile(emailTemplate.body); + return bodyTemplate(order); + } + + async sendAllMails(toEmail, copyTo, subject, body) { + await Promise.all([ + mailer.send({ + to: toEmail, + subject: subject, + html: body + }), + mailer.send({ + to: copyTo, + subject: subject, + html: body + }) + ]); + } + + async checkoutOrder(orderId) { + /* + TODO: + - check order exists + - check order not placed + - fire Webhooks + */ + const [order, emailTemplate, dashboardSettings] = await Promise.all([ + this.getOrCreateCustomer(orderId).then(customer_id => { + return this.updateOrder(orderId, { + customer_id: customer_id, + date_placed: new Date(), + draft: false + }); + }), + EmailTemplatesService.getEmailTemplate('order_confirmation'), + SettingsService.getSettings() + ]); + + const subject = this.getEmailSubject(emailTemplate, order); + const body = this.getEmailBody(emailTemplate, order); + const copyTo = dashboardSettings.order_confirmation_copy_to; + + dashboardWebSocket.send({ + event: dashboardWebSocket.events.ORDER_CREATED, + payload: order + }); + + await Promise.all([ + webhooks.trigger({ + event: webhooks.events.ORDER_CREATED, + payload: order + }), + this.sendAllMails(order.email, copyTo, subject, body), + ProductStockService.handleOrderCheckout(orderId) + ]); + + return order; + } + + cancelOrder(orderId) { + const orderData = { + cancelled: true, + date_cancelled: new Date() + }; + + return ProductStockService.handleCancelOrder(orderId).then(() => + this.updateOrder(orderId, orderData) + ); + } + + closeOrder(orderId) { + const orderData = { + closed: true, + date_closed: new Date() + }; + + return this.updateOrder(orderId, orderData); + } + + updateCustomerStatistics(customerId) { + if (customerId) { + return this.getOrders({ customer_id: customerId }).then(orders => { + let totalSpent = 0; + let ordersCount = 0; + + if (orders.data && orders.data.length > 0) { + for (const order of orders.data) { + if (order.draft === false) { + ordersCount++; + } + if (order.paid === true || order.closed === true) { + totalSpent += order.grand_total; + } + } + } + + return CustomersService.updateCustomerStatistics( + customerId, + totalSpent, + ordersCount + ); + }); + } else { + return null; + } + } + + async chargeOrder(orderId) { + const order = await this.getSingleOrder(orderId); + const isSuccess = await PaymentGateways.processOrderPayment(order); + return isSuccess; + } +} + +export default new OrdersService(); diff --git a/src/api/server/services/orders/paymentMethods.js b/src/api/server/services/orders/paymentMethods.js new file mode 100755 index 0000000..61665e5 --- /dev/null +++ b/src/api/server/services/orders/paymentMethods.js @@ -0,0 +1,241 @@ +import { ObjectID } from 'mongodb'; +import { db } from '../../lib/mongo'; +import utils from '../../lib/utils'; +import parse from '../../lib/parse'; +import PaymentMethodsLightService from './paymentMethodsLight'; +import OrdersService from './orders'; + +class PaymentMethodsService { + constructor() {} + + getFilter(params = {}) { + return new Promise((resolve, reject) => { + let filter = {}; + const id = parse.getObjectIDIfValid(params.id); + const enabled = parse.getBooleanIfValid(params.enabled); + + if (id) { + filter._id = new ObjectID(id); + } + + if (enabled !== null) { + filter.enabled = enabled; + } + + const order_id = parse.getObjectIDIfValid(params.order_id); + + if (order_id) { + return OrdersService.getSingleOrder(order_id).then(order => { + if (order) { + const shippingMethodObjectID = parse.getObjectIDIfValid( + order.shipping_method_id + ); + + filter['$and'] = []; + filter['$and'].push({ + $or: [ + { + 'conditions.subtotal_min': 0 + }, + { + 'conditions.subtotal_min': { + $lte: order.subtotal + } + } + ] + }); + filter['$and'].push({ + $or: [ + { + 'conditions.subtotal_max': 0 + }, + { + 'conditions.subtotal_max': { + $gte: order.subtotal + } + } + ] + }); + + if ( + order.shipping_address.country && + order.shipping_address.country.length > 0 + ) { + filter['$and'].push({ + $or: [ + { + 'conditions.countries': { + $size: 0 + } + }, + { + 'conditions.countries': order.shipping_address.country + } + ] + }); + } + + if (shippingMethodObjectID) { + filter['$and'].push({ + $or: [ + { + 'conditions.shipping_method_ids': { + $size: 0 + } + }, + { + 'conditions.shipping_method_ids': shippingMethodObjectID + } + ] + }); + } + } + resolve(filter); + }); + } else { + resolve(filter); + } + }); + } + + getMethods(params = {}) { + return this.getFilter(params).then(filter => { + return PaymentMethodsLightService.getMethods(filter); + }); + } + + getSingleMethod(id) { + if (!ObjectID.isValid(id)) { + return Promise.reject('Invalid identifier'); + } + return this.getMethods({ id: id }).then(methods => { + return methods.length > 0 ? methods[0] : null; + }); + } + + addMethod(data) { + const method = this.getValidDocumentForInsert(data); + return db + .collection('paymentMethods') + .insertMany([method]) + .then(res => this.getSingleMethod(res.ops[0]._id.toString())); + } + + updateMethod(id, data) { + if (!ObjectID.isValid(id)) { + return Promise.reject('Invalid identifier'); + } + const methodObjectID = new ObjectID(id); + const method = this.getValidDocumentForUpdate(id, data); + + return db + .collection('paymentMethods') + .updateOne( + { + _id: methodObjectID + }, + { $set: method } + ) + .then(res => this.getSingleMethod(id)); + } + + deleteMethod(id) { + if (!ObjectID.isValid(id)) { + return Promise.reject('Invalid identifier'); + } + const methodObjectID = new ObjectID(id); + return db + .collection('paymentMethods') + .deleteOne({ _id: methodObjectID }) + .then(deleteResponse => { + return deleteResponse.deletedCount > 0; + }); + } + + async pullShippingMethod(id) { + if (!ObjectID.isValid(id)) { + return Promise.reject('Invalid identifier'); + } + const methodObjectID = new ObjectID(id); + return db + .collection('paymentMethods') + .update( + {}, + { $pull: { 'conditions.shipping_method_ids': methodObjectID } }, + { multi: true } + ); + } + + getPaymentMethodConditions(conditions) { + let methodIds = conditions + ? parse.getArrayIfValid(conditions.shipping_method_ids) || [] + : []; + let methodObjects = []; + if (methodIds.length > 0) { + methodObjects = methodIds.map(id => new ObjectID(id)); + } + + return conditions + ? { + countries: parse.getArrayIfValid(conditions.countries) || [], + shipping_method_ids: methodObjects, + subtotal_min: parse.getNumberIfPositive(conditions.subtotal_min) || 0, + subtotal_max: parse.getNumberIfPositive(conditions.subtotal_max) || 0 + } + : { + countries: [], + shipping_method_ids: [], + subtotal_min: 0, + subtotal_max: 0 + }; + } + + getValidDocumentForInsert(data) { + let method = {}; + + method.name = parse.getString(data.name); + method.description = parse.getString(data.description); + method.position = parse.getNumberIfPositive(data.position) || 0; + method.enabled = parse.getBooleanIfValid(data.enabled, true); + method.conditions = this.getPaymentMethodConditions(data.conditions); + method.gateway = parse.getString(data.gateway); + + return method; + } + + getValidDocumentForUpdate(id, data) { + if (Object.keys(data).length === 0) { + return new Error('Required fields are missing'); + } + + let method = {}; + + if (data.name !== undefined) { + method.name = parse.getString(data.name); + } + + if (data.description !== undefined) { + method.description = parse.getString(data.description); + } + + if (data.position !== undefined) { + method.position = parse.getNumberIfPositive(data.position) || 0; + } + + if (data.enabled !== undefined) { + method.enabled = parse.getBooleanIfValid(data.enabled, true); + } + + if (data.conditions !== undefined) { + method.conditions = this.getPaymentMethodConditions(data.conditions); + } + + if (data.gateway !== undefined) { + method.gateway = parse.getString(data.gateway); + } + + return method; + } +} + +export default new PaymentMethodsService(); diff --git a/src/api/server/services/orders/paymentMethodsLight.js b/src/api/server/services/orders/paymentMethodsLight.js new file mode 100755 index 0000000..ca72da6 --- /dev/null +++ b/src/api/server/services/orders/paymentMethodsLight.js @@ -0,0 +1,23 @@ +import { db } from '../../lib/mongo'; + +class PaymentMethodsLightService { + constructor() {} + + getMethods(filter = {}) { + return db + .collection('paymentMethods') + .find(filter) + .toArray() + .then(items => items.map(item => this.changeProperties(item))); + } + + changeProperties(item) { + if (item) { + item.id = item._id.toString(); + delete item._id; + } + return item; + } +} + +export default new PaymentMethodsLightService(); diff --git a/src/api/server/services/orders/shippingMethods.js b/src/api/server/services/orders/shippingMethods.js new file mode 100755 index 0000000..227a96a --- /dev/null +++ b/src/api/server/services/orders/shippingMethods.js @@ -0,0 +1,290 @@ +import { ObjectID } from 'mongodb'; +import { db } from '../../lib/mongo'; +import utils from '../../lib/utils'; +import parse from '../../lib/parse'; +import ShippingMethodsLightService from './shippingMethodsLight'; +import PaymentMethodsService from './paymentMethods'; +import OrdersService from './orders'; + +class ShippingMethodsService { + constructor() {} + + getFilter(params = {}) { + return new Promise((resolve, reject) => { + let filter = {}; + const id = parse.getObjectIDIfValid(params.id); + const enabled = parse.getBooleanIfValid(params.enabled); + + if (id) { + filter._id = new ObjectID(id); + } + + if (enabled !== null) { + filter.enabled = enabled; + } + + const order_id = parse.getObjectIDIfValid(params.order_id); + if (order_id) { + return OrdersService.getSingleOrder(order_id).then(order => { + if (order) { + filter['$and'] = []; + filter['$and'].push({ + $or: [ + { + 'conditions.weight_total_min': 0 + }, + { + 'conditions.weight_total_min': { + $lte: order.weight_total + } + } + ] + }); + filter['$and'].push({ + $or: [ + { + 'conditions.weight_total_max': 0 + }, + { + 'conditions.weight_total_max': { + $gte: order.weight_total + } + } + ] + }); + + filter['$and'].push({ + $or: [ + { + 'conditions.subtotal_min': 0 + }, + { + 'conditions.subtotal_min': { + $lte: order.subtotal + } + } + ] + }); + filter['$and'].push({ + $or: [ + { + 'conditions.subtotal_max': 0 + }, + { + 'conditions.subtotal_max': { + $gte: order.subtotal + } + } + ] + }); + + if ( + order.shipping_address.country && + order.shipping_address.country.length > 0 + ) { + filter['$and'].push({ + $or: [ + { + 'conditions.countries': { + $size: 0 + } + }, + { + 'conditions.countries': order.shipping_address.country + } + ] + }); + } + + if ( + order.shipping_address.state && + order.shipping_address.state.length > 0 + ) { + filter['$and'].push({ + $or: [ + { + 'conditions.states': { + $size: 0 + } + }, + { + 'conditions.states': order.shipping_address.state + } + ] + }); + } + + if ( + order.shipping_address.city && + order.shipping_address.city.length > 0 + ) { + filter['$and'].push({ + $or: [ + { + 'conditions.cities': { + $size: 0 + } + }, + { + 'conditions.cities': order.shipping_address.city + } + ] + }); + } + } + resolve(filter); + }); + } else { + resolve(filter); + } + }); + } + + getMethods(params = {}) { + return this.getFilter(params).then(filter => { + return ShippingMethodsLightService.getMethods(filter); + }); + } + + getSingleMethod(id) { + if (!ObjectID.isValid(id)) { + return Promise.reject('Invalid identifier'); + } + return this.getMethods({ id: id }).then(methods => { + return methods.length > 0 ? methods[0] : null; + }); + } + + addMethod(data) { + const method = this.getValidDocumentForInsert(data); + return db + .collection('shippingMethods') + .insertMany([method]) + .then(res => this.getSingleMethod(res.ops[0]._id.toString())); + } + + updateMethod(id, data) { + if (!ObjectID.isValid(id)) { + return Promise.reject('Invalid identifier'); + } + const methodObjectID = new ObjectID(id); + const method = this.getValidDocumentForUpdate(id, data); + + return db + .collection('shippingMethods') + .updateOne( + { + _id: methodObjectID + }, + { $set: method } + ) + .then(res => this.getSingleMethod(id)); + } + + async deleteMethod(id) { + if (!ObjectID.isValid(id)) { + return Promise.reject('Invalid identifier'); + } + const methodObjectID = new ObjectID(id); + const deleteResponse = await db + .collection('shippingMethods') + .deleteOne({ _id: methodObjectID }); + + await PaymentMethodsService.pullShippingMethod(id); + return deleteResponse.deletedCount > 0; + } + + getShippingMethodConditions(conditions) { + return conditions + ? { + countries: parse.getArrayIfValid(conditions.countries) || [], + states: parse.getArrayIfValid(conditions.states) || [], + cities: parse.getArrayIfValid(conditions.cities) || [], + subtotal_min: parse.getNumberIfPositive(conditions.subtotal_min) || 0, + subtotal_max: parse.getNumberIfPositive(conditions.subtotal_max) || 0, + weight_total_min: + parse.getNumberIfPositive(conditions.weight_total_min) || 0, + weight_total_max: + parse.getNumberIfPositive(conditions.weight_total_max) || 0 + } + : { + countries: [], + states: [], + cities: [], + subtotal_min: 0, + subtotal_max: 0, + weight_total_min: 0, + weight_total_max: 0 + }; + } + + getFields(fields) { + if (fields && Array.isArray(fields) && fields.length > 0) { + return fields.map(field => ({ + key: parse.getString(field.key), + label: parse.getString(field.label), + required: parse.getBooleanIfValid(field.required, false) + })); + } else { + return []; + } + } + + getValidDocumentForInsert(data) { + let method = { + // 'logo': '', + // 'app_id': null, + // 'app_settings': {} + }; + + method.name = parse.getString(data.name); + method.description = parse.getString(data.description); + method.position = parse.getNumberIfPositive(data.position) || 0; + method.enabled = parse.getBooleanIfValid(data.enabled, true); + method.price = parse.getNumberIfPositive(data.price) || 0; + method.conditions = this.getShippingMethodConditions(data.conditions); + method.fields = this.getFields(data.fields); + + return method; + } + + getValidDocumentForUpdate(id, data) { + if (Object.keys(data).length === 0) { + return new Error('Required fields are missing'); + } + + let method = {}; + + if (data.name !== undefined) { + method.name = parse.getString(data.name); + } + + if (data.description !== undefined) { + method.description = parse.getString(data.description); + } + + if (data.position !== undefined) { + method.position = parse.getNumberIfPositive(data.position) || 0; + } + + if (data.enabled !== undefined) { + method.enabled = parse.getBooleanIfValid(data.enabled, true); + } + + if (data.price !== undefined) { + method.price = parse.getNumberIfPositive(data.price) || 0; + } + + if (data.conditions !== undefined) { + method.conditions = this.getShippingMethodConditions(data.conditions); + } + + if (data.fields !== undefined) { + method.fields = this.getFields(data.fields); + } + + return method; + } +} + +export default new ShippingMethodsService(); diff --git a/src/api/server/services/orders/shippingMethodsLight.js b/src/api/server/services/orders/shippingMethodsLight.js new file mode 100755 index 0000000..0731046 --- /dev/null +++ b/src/api/server/services/orders/shippingMethodsLight.js @@ -0,0 +1,35 @@ +import { ObjectID } from 'mongodb'; +import { db } from '../../lib/mongo'; + +class ShippingMethodsLightService { + constructor() {} + + getMethods(filter = {}) { + return db + .collection('shippingMethods') + .find(filter) + .toArray() + .then(items => items.map(item => this.changeProperties(item))); + } + + getMethodPrice(id) { + let filter = {}; + if (id) { + filter._id = new ObjectID(id); + } + + return this.getMethods(filter).then(methods => { + return methods.length > 0 ? methods[0].price || 0 : 0; + }); + } + + changeProperties(item) { + if (item) { + item.id = item._id.toString(); + delete item._id; + } + return item; + } +} + +export default new ShippingMethodsLightService(); diff --git a/src/api/server/services/pages/pages.js b/src/api/server/services/pages/pages.js new file mode 100755 index 0000000..cd40647 --- /dev/null +++ b/src/api/server/services/pages/pages.js @@ -0,0 +1,191 @@ +import { ObjectID } from 'mongodb'; +import url from 'url'; +import settings from '../../lib/settings'; +import { db } from '../../lib/mongo'; +import utils from '../../lib/utils'; +import parse from '../../lib/parse'; +import SettingsService from '../settings/settings'; + +const DEFAULT_SORT = { is_system: -1, date_created: 1 }; + +class PagesService { + constructor() {} + + getFilter(params = {}) { + let filter = {}; + const id = parse.getObjectIDIfValid(params.id); + const tags = parse.getString(params.tags); + if (id) { + filter._id = new ObjectID(id); + } + if (tags && tags.length > 0) { + filter.tags = tags; + } + return filter; + } + + getSortQuery({ sort }) { + if (sort && sort.length > 0) { + const fields = sort.split(','); + return Object.assign( + ...fields.map(field => ({ + [field.startsWith('-') ? field.slice(1) : field]: field.startsWith( + '-' + ) + ? -1 + : 1 + })) + ); + } else { + return DEFAULT_SORT; + } + } + + async getPages(params = {}) { + const filter = this.getFilter(params); + const sortQuery = this.getSortQuery(params); + const projection = utils.getProjectionFromFields(params.fields); + const generalSettings = await SettingsService.getSettings(); + const domain = generalSettings.domain; + const items = await db + .collection('pages') + .find(filter, { projection: projection }) + .sort(sortQuery) + .toArray(); + const result = items.map(page => this.changeProperties(page, domain)); + return result; + } + + getSinglePage(id) { + if (!ObjectID.isValid(id)) { + return Promise.reject('Invalid identifier'); + } + return this.getPages({ id: id }).then(pages => { + return pages.length > 0 ? pages[0] : null; + }); + } + + addPage(data) { + return this.getValidDocumentForInsert(data).then(page => + db + .collection('pages') + .insertMany([page]) + .then(res => this.getSinglePage(res.ops[0]._id.toString())) + ); + } + + updatePage(id, data) { + if (!ObjectID.isValid(id)) { + return Promise.reject('Invalid identifier'); + } + const pageObjectID = new ObjectID(id); + + return this.getValidDocumentForUpdate(id, data).then(page => + db + .collection('pages') + .updateOne({ _id: pageObjectID }, { $set: page }) + .then(res => this.getSinglePage(id)) + ); + } + + deletePage(id) { + if (!ObjectID.isValid(id)) { + return Promise.reject('Invalid identifier'); + } + const pageObjectID = new ObjectID(id); + return db + .collection('pages') + .deleteOne({ _id: pageObjectID, is_system: false }) + .then(deleteResponse => { + return deleteResponse.deletedCount > 0; + }); + } + + getValidDocumentForInsert(data) { + let page = { + is_system: false, + date_created: new Date() + }; + + page.content = parse.getString(data.content); + page.meta_description = parse.getString(data.meta_description); + page.meta_title = parse.getString(data.meta_title); + page.enabled = parse.getBooleanIfValid(data.enabled, true); + page.tags = parse.getArrayIfValid(data.tags) || []; + + let slug = + !data.slug || data.slug.length === 0 ? data.meta_title : data.slug; + if (!slug || slug.length === 0) { + return Promise.resolve(page); + } else { + return utils.getAvailableSlug(slug, null, false).then(newSlug => { + page.slug = newSlug; + return page; + }); + } + } + + getValidDocumentForUpdate(id, data) { + if (Object.keys(data).length === 0) { + return Promise.reject('Required fields are missing'); + } else { + return this.getSinglePage(id).then(prevPageData => { + let page = { + date_updated: new Date() + }; + + if (data.content !== undefined) { + page.content = parse.getString(data.content); + } + + if (data.meta_description !== undefined) { + page.meta_description = parse.getString(data.meta_description); + } + + if (data.meta_title !== undefined) { + page.meta_title = parse.getString(data.meta_title); + } + + if (data.enabled !== undefined && !prevPageData.is_system) { + page.enabled = parse.getBooleanIfValid(data.enabled, true); + } + + if (data.tags !== undefined) { + page.tags = parse.getArrayIfValid(data.tags) || []; + } + + if (data.slug !== undefined && !prevPageData.is_system) { + let slug = data.slug; + if (!slug || slug.length === 0) { + slug = data.meta_title; + } + + return utils.getAvailableSlug(slug, id, false).then(newSlug => { + page.slug = newSlug; + return page; + }); + } else { + return page; + } + }); + } + } + + changeProperties(item, domain) { + if (item) { + item.id = item._id.toString(); + item._id = undefined; + + if (!item.slug) { + item.slug = ''; + } + + item.url = url.resolve(domain, `/${item.slug}`); + item.path = url.resolve('/', item.slug); + } + + return item; + } +} + +export default new PagesService(); diff --git a/src/api/server/services/products/images.js b/src/api/server/services/products/images.js new file mode 100755 index 0000000..e620b3b --- /dev/null +++ b/src/api/server/services/products/images.js @@ -0,0 +1,180 @@ +import { ObjectID } from 'mongodb'; +import path from 'path'; +import url from 'url'; +import formidable from 'formidable'; +import fse from 'fs-extra'; +import settings from '../../lib/settings'; +import { db } from '../../lib/mongo'; +import utils from '../../lib/utils'; +import parse from '../../lib/parse'; +import SettingsService from '../settings/settings'; + +class ProductImagesService { + constructor() {} + + getErrorMessage(err) { + return { error: true, message: err.toString() }; + } + + getImages(productId) { + if (!ObjectID.isValid(productId)) { + return Promise.reject('Invalid identifier'); + } + let productObjectID = new ObjectID(productId); + + return SettingsService.getSettings().then(generalSettings => + db + .collection('products') + .findOne({ _id: productObjectID }, { fields: { images: 1 } }) + .then(product => { + if (product && product.images && product.images.length > 0) { + let images = product.images.map(image => { + image.url = url.resolve( + generalSettings.domain, + settings.productsUploadUrl + + '/' + + product._id + + '/' + + image.filename + ); + return image; + }); + + images = images.sort((a, b) => a.position - b.position); + return images; + } else { + return []; + } + }) + ); + } + + deleteImage(productId, imageId) { + if (!ObjectID.isValid(productId) || !ObjectID.isValid(imageId)) { + return Promise.reject('Invalid identifier'); + } + let productObjectID = new ObjectID(productId); + let imageObjectID = new ObjectID(imageId); + + return this.getImages(productId) + .then(images => { + if (images && images.length > 0) { + let imageData = images.find( + i => i.id.toString() === imageId.toString() + ); + if (imageData) { + let filename = imageData.filename; + let filepath = path.resolve( + settings.productsUploadPath + '/' + productId + '/' + filename + ); + fse.removeSync(filepath); + return db + .collection('products') + .updateOne( + { _id: productObjectID }, + { $pull: { images: { id: imageObjectID } } } + ); + } else { + return true; + } + } else { + return true; + } + }) + .then(() => true); + } + + async addImage(req, res) { + const productId = req.params.productId; + if (!ObjectID.isValid(productId)) { + res.status(500).send(this.getErrorMessage('Invalid identifier')); + return; + } + + let uploadedFiles = []; + const productObjectID = new ObjectID(productId); + const uploadDir = path.resolve( + settings.productsUploadPath + '/' + productId + ); + fse.ensureDirSync(uploadDir); + + let form = new formidable.IncomingForm(); + form.uploadDir = uploadDir; + + form + .on('fileBegin', (name, file) => { + // Emitted whenever a field / value pair has been received. + file.name = utils.getCorrectFileName(file.name); + file.path = uploadDir + '/' + file.name; + }) + .on('file', async (field, file) => { + // every time a file has been uploaded successfully, + if (file.name) { + const imageData = { + id: new ObjectID(), + alt: '', + position: 99, + filename: file.name + }; + + uploadedFiles.push(imageData); + + await db.collection('products').updateOne( + { + _id: productObjectID + }, + { + $push: { images: imageData } + } + ); + } + }) + .on('error', err => { + res.status(500).send(this.getErrorMessage(err)); + }) + .on('end', () => { + res.send(uploadedFiles); + }); + + form.parse(req); + } + + updateImage(productId, imageId, data) { + if (!ObjectID.isValid(productId) || !ObjectID.isValid(imageId)) { + return Promise.reject('Invalid identifier'); + } + let productObjectID = new ObjectID(productId); + let imageObjectID = new ObjectID(imageId); + + const imageData = this.getValidDocumentForUpdate(data); + + return db.collection('products').updateOne( + { + _id: productObjectID, + 'images.id': imageObjectID + }, + { $set: imageData } + ); + } + + getValidDocumentForUpdate(data) { + if (Object.keys(data).length === 0) { + return new Error('Required fields are missing'); + } + + let image = {}; + + if (data.alt !== undefined) { + image['images.$.alt'] = parse.getString(data.alt); + } + + if (data.position !== undefined) { + image['images.$.position'] = + parse.getNumberIfPositive(data.position) || 0; + } + + return image; + } +} + +export default new ProductImagesService(); diff --git a/src/api/server/services/products/optionValues.js b/src/api/server/services/products/optionValues.js new file mode 100755 index 0000000..aaaaa92 --- /dev/null +++ b/src/api/server/services/products/optionValues.js @@ -0,0 +1,147 @@ +import { ObjectID } from 'mongodb'; +import { db } from '../../lib/mongo'; +import parse from '../../lib/parse'; + +class ProductOptionValuesService { + constructor() {} + + getOptionValues(productId, optionId) { + let productObjectID = new ObjectID(productId); + + return db + .collection('products') + .findOne({ _id: productObjectID }, { fields: { options: 1 } }) + .then(product => (product && product.options ? product.options : null)) + .then( + options => + options && options.length > 0 + ? options.find(option => option.id.toString() === optionId) + : null + ) + .then( + option => (option && option.values.length > 0 ? option.values : []) + ); + } + + getSingleOptionValue(productId, optionId, valueId) { + return this.getOptionValues(productId, optionId).then(optionValues => + optionValues.find(optionValue => optionValue.id.toString() === valueId) + ); + } + + addOptionValue(productId, optionId, data) { + if (!ObjectID.isValid(productId) || !ObjectID.isValid(optionId)) { + return Promise.reject('Invalid identifier'); + } + let productObjectID = new ObjectID(productId); + let optionObjectID = new ObjectID(optionId); + + const optionValueData = this.getValidDocumentForInsert(data); + + return db + .collection('products') + .updateOne( + { + _id: productObjectID, + 'options.id': optionObjectID + }, + { $push: { 'options.$.values': optionValueData } } + ) + .then(res => this.getOptionValues(productId, optionId)); + } + + updateOptionValue(productId, optionId, valueId, data) { + if ( + !ObjectID.isValid(productId) || + !ObjectID.isValid(optionId) || + !ObjectID.isValid(valueId) + ) { + return Promise.reject('Invalid identifier'); + } + + if (data.name !== undefined) { + return this.getModifiedOptionValues( + productId, + optionId, + valueId, + data.name + ) + .then(values => + this.overwriteAllValuesForOption(productId, optionId, values) + ) + .then(updateResult => this.getOptionValues(productId, optionId)); + } else { + return Promise.reject('Please, specify value name'); + } + } + + deleteOptionValue(productId, optionId, valueId) { + if ( + !ObjectID.isValid(productId) || + !ObjectID.isValid(optionId) || + !ObjectID.isValid(valueId) + ) { + return Promise.reject('Invalid identifier'); + } + + return this.getOptionValuesWithDeletedOne(productId, optionId, valueId) + .then(values => + this.overwriteAllValuesForOption(productId, optionId, values) + ) + .then(updateResult => this.getOptionValues(productId, optionId)); + } + + getModifiedOptionValues(productId, optionId, valueId, name) { + return this.getOptionValues(productId, optionId).then(values => { + if (values && values.length > 0) { + values = values.map(value => { + if (value.id.toString() === valueId) { + value.name = name; + return value; + } else { + return value; + } + }); + } + + return values; + }); + } + + getOptionValuesWithDeletedOne(productId, optionId, deleteValueId) { + return this.getOptionValues(productId, optionId).then(values => { + if (values && values.length > 0) { + values = values.filter(value => value.id.toString() !== deleteValueId); + } + + return values; + }); + } + + overwriteAllValuesForOption(productId, optionId, values) { + let productObjectID = new ObjectID(productId); + let optionObjectID = new ObjectID(optionId); + + if (!values) { + return; + } + + return db + .collection('products') + .updateOne( + { _id: productObjectID, 'options.id': optionObjectID }, + { $set: { 'options.$.values': values } } + ); + } + + getValidDocumentForInsert(data) { + let optionValue = { + id: new ObjectID(), + name: parse.getString(data.name) + }; + + return optionValue; + } +} + +export default new ProductOptionValuesService(); diff --git a/src/api/server/services/products/options.js b/src/api/server/services/products/options.js new file mode 100755 index 0000000..18175df --- /dev/null +++ b/src/api/server/services/products/options.js @@ -0,0 +1,158 @@ +import { ObjectID } from 'mongodb'; +import { db } from '../../lib/mongo'; +import parse from '../../lib/parse'; + +class ProductOptionsService { + constructor() {} + + getOptions(productId) { + if (!ObjectID.isValid(productId)) { + return Promise.reject('Invalid identifier'); + } + let productObjectID = new ObjectID(productId); + + return db + .collection('products') + .findOne({ _id: productObjectID }, { fields: { options: 1 } }) + .then(product => { + if (product && product.options && product.options.length > 0) { + return product.options + .map(option => this.changeProperties(option)) + .sort((a, b) => a.position - b.position); + } else { + return []; + } + }); + } + + getSingleOption(productId, optionId) { + return this.getOptions(productId).then(options => + options.find(option => option.id === optionId) + ); + } + + deleteOption(productId, optionId) { + if (!ObjectID.isValid(productId) || !ObjectID.isValid(optionId)) { + return Promise.reject('Invalid identifier'); + } + let productObjectID = new ObjectID(productId); + let optionObjectID = new ObjectID(optionId); + + return db + .collection('products') + .updateOne( + { + _id: productObjectID + }, + { + $pull: { + options: { + id: optionObjectID + } + } + } + ) + .then(res => this.getOptions(productId)); + } + + addOption(productId, data) { + if (!ObjectID.isValid(productId)) { + return Promise.reject('Invalid identifier'); + } + let productObjectID = new ObjectID(productId); + + const optionData = this.getValidDocumentForInsert(data); + + return db + .collection('products') + .updateOne({ _id: productObjectID }, { $push: { options: optionData } }) + .then(res => this.getOptions(productId)); + } + + updateOption(productId, optionId, data) { + if (!ObjectID.isValid(productId) || !ObjectID.isValid(optionId)) { + return Promise.reject('Invalid identifier'); + } + let productObjectID = new ObjectID(productId); + let optionObjectID = new ObjectID(optionId); + + const optionData = this.getValidDocumentForUpdate(data); + + return db + .collection('products') + .updateOne( + { + _id: productObjectID, + 'options.id': optionObjectID + }, + { $set: optionData } + ) + .then(res => this.getOptions(productId)); + } + + getValidDocumentForInsert(data) { + let option = { + id: new ObjectID(), + name: parse.getString(data.name), + control: parse.getString(data.control), + required: parse.getBooleanIfValid(data.required, true), + position: parse.getNumberIfPositive(data.position) || 0, + values: [] + }; + + if (option.control === '') { + option.control = 'select'; + } + + return option; + } + + getValidDocumentForUpdate(data) { + if (Object.keys(data).length === 0) { + return new Error('Required fields are missing'); + } + + let option = {}; + + if (data.name !== undefined) { + option['options.$.name'] = parse.getString(data.name); + } + + if (data.control !== undefined) { + option['options.$.control'] = parse.getString(data.control); + } + + if (data.required !== undefined) { + option['options.$.required'] = parse.getBooleanIfValid( + data.required, + true + ); + } + + if (data.position !== undefined) { + option['options.$.position'] = + parse.getNumberIfPositive(data.position) || 0; + } + + return option; + } + + changeProperties(item) { + if (item) { + if (item.id) { + item.id = item.id.toString(); + } + + if (item.values && item.values.length > 0) { + item.values = item.values.map(value => { + value.id = value.id.toString(); + return value; + }); + } + } + + return item; + } +} + +export default new ProductOptionsService(); diff --git a/src/api/server/services/products/productCategories.js b/src/api/server/services/products/productCategories.js new file mode 100755 index 0000000..94135aa --- /dev/null +++ b/src/api/server/services/products/productCategories.js @@ -0,0 +1,327 @@ +import { ObjectID } from 'mongodb'; +import path from 'path'; +import url from 'url'; +import formidable from 'formidable'; +import fse from 'fs-extra'; +import settings from '../../lib/settings'; +import SettingsService from '../settings/settings'; +import { db } from '../../lib/mongo'; +import utils from '../../lib/utils'; +import parse from '../../lib/parse'; + +class ProductCategoriesService { + constructor() {} + + getFilter(params = {}) { + let filter = {}; + const enabled = parse.getBooleanIfValid(params.enabled); + if (enabled !== null) { + filter.enabled = enabled; + } + const id = parse.getObjectIDIfValid(params.id); + if (id) { + filter._id = id; + } + return filter; + } + + async getCategories(params = {}) { + const filter = this.getFilter(params); + const projection = utils.getProjectionFromFields(params.fields); + const generalSettings = await SettingsService.getSettings(); + const domain = generalSettings.domain; + const items = await db + .collection('productCategories') + .find(filter, { projection: projection }) + .sort({ position: 1 }) + .toArray(); + const result = items.map(category => + this.changeProperties(category, domain) + ); + return result; + } + + getSingleCategory(id) { + if (!ObjectID.isValid(id)) { + return Promise.reject('Invalid identifier'); + } + return this.getCategories({ id: id }).then(categories => { + return categories.length > 0 ? categories[0] : null; + }); + } + + async addCategory(data) { + const lastCategory = await db + .collection('productCategories') + .findOne({}, { sort: { position: -1 } }); + const newPosition = + lastCategory && lastCategory.position > 0 ? lastCategory.position + 1 : 1; + const dataToInsert = await this.getValidDocumentForInsert( + data, + newPosition + ); + const insertResult = await db + .collection('productCategories') + .insertMany([dataToInsert]); + return this.getSingleCategory(insertResult.ops[0]._id.toString()); + } + + updateCategory(id, data) { + if (!ObjectID.isValid(id)) { + return Promise.reject('Invalid identifier'); + } + let categoryObjectID = new ObjectID(id); + + return this.getValidDocumentForUpdate(id, data) + .then(dataToSet => + db + .collection('productCategories') + .updateOne({ _id: categoryObjectID }, { $set: dataToSet }) + ) + .then(res => (res.modifiedCount > 0 ? this.getSingleCategory(id) : null)); + } + + findAllChildren(items, id, result) { + if (id && ObjectID.isValid(id)) { + result.push(new ObjectID(id)); + let finded = items.filter( + item => (item.parent_id || '').toString() === id.toString() + ); + if (finded.length > 0) { + for (let item of finded) { + this.findAllChildren(items, item.id, result); + } + } + } + + return result; + } + + deleteCategory(id) { + if (!ObjectID.isValid(id)) { + return Promise.reject('Invalid identifier'); + } + + // 1. get all categories + return this.getCategories() + .then(items => { + // 2. find category and children + let idsToDelete = []; + this.findAllChildren(items, id, idsToDelete); + return idsToDelete; + }) + .then(idsToDelete => { + // 3. delete categories + let objectsToDelete = idsToDelete.map(id => new ObjectID(id)); + // return db.collection('productCategories').deleteMany({_id: { $in: objectsToDelete}}).then(() => idsToDelete); + return db + .collection('productCategories') + .deleteMany({ _id: { $in: objectsToDelete } }) + .then( + deleteResponse => + deleteResponse.deletedCount > 0 ? idsToDelete : null + ); + }) + .then(idsToDelete => { + // 4. update category_id for products + return idsToDelete + ? db + .collection('products') + .updateMany( + { category_id: { $in: idsToDelete } }, + { $set: { category_id: null } } + ) + .then(() => idsToDelete) + : null; + }) + .then(idsToDelete => { + // 5. delete directories with images + if (idsToDelete) { + for (let categoryId of idsToDelete) { + let deleteDir = path.resolve( + settings.categoriesUploadPath + '/' + categoryId + ); + fse.remove(deleteDir, err => {}); + } + return Promise.resolve(true); + } else { + return Promise.resolve(false); + } + }); + } + + getErrorMessage(err) { + return { error: true, message: err.toString() }; + } + + getValidDocumentForInsert(data, newPosition) { + // Allow empty category to create draft + + let category = { + date_created: new Date(), + date_updated: null, + image: '' + }; + + category.name = parse.getString(data.name); + category.description = parse.getString(data.description); + category.meta_description = parse.getString(data.meta_description); + category.meta_title = parse.getString(data.meta_title); + category.enabled = parse.getBooleanIfValid(data.enabled, true); + category.sort = parse.getString(data.sort); + category.parent_id = parse.getObjectIDIfValid(data.parent_id); + category.position = parse.getNumberIfValid(data.position) || newPosition; + + let slug = !data.slug || data.slug.length === 0 ? data.name : data.slug; + if (!slug || slug.length === 0) { + return Promise.resolve(category); + } else { + return utils.getAvailableSlug(slug).then(newSlug => { + category.slug = newSlug; + return category; + }); + } + } + + getValidDocumentForUpdate(id, data) { + return new Promise((resolve, reject) => { + if (!ObjectID.isValid(id)) { + reject('Invalid identifier'); + } + if (Object.keys(data).length === 0) { + reject('Required fields are missing'); + } + + let category = { + date_updated: new Date() + }; + + if (data.name !== undefined) { + category.name = parse.getString(data.name); + } + + if (data.description !== undefined) { + category.description = parse.getString(data.description); + } + + if (data.meta_description !== undefined) { + category.meta_description = parse.getString(data.meta_description); + } + + if (data.meta_title !== undefined) { + category.meta_title = parse.getString(data.meta_title); + } + + if (data.enabled !== undefined) { + category.enabled = parse.getBooleanIfValid(data.enabled, true); + } + + if (data.image !== undefined) { + category.image = data.image; + } + + if (data.position >= 0) { + category.position = data.position; + } + + if (data.sort !== undefined) { + category.sort = data.sort; + } + + if (data.parent_id !== undefined) { + category.parent_id = parse.getObjectIDIfValid(data.parent_id); + } + + if (data.slug !== undefined) { + let slug = data.slug; + if (!slug || slug.length === 0) { + slug = data.name; + } + + utils + .getAvailableSlug(slug, id) + .then(newSlug => { + category.slug = newSlug; + resolve(category); + }) + .catch(err => { + reject(err); + }); + } else { + resolve(category); + } + }); + } + + changeProperties(item, domain) { + if (item) { + item.id = item._id.toString(); + item._id = undefined; + + if (item.parent_id) { + item.parent_id = item.parent_id.toString(); + } + + if (item.slug) { + item.url = url.resolve(domain, `/${item.slug}`); + item.path = url.resolve('/', item.slug); + } + + if (item.image) { + item.image = url.resolve( + domain, + `${settings.categoriesUploadUrl}/${item.id}/${item.image}` + ); + } + } + + return item; + } + + deleteCategoryImage(id) { + let dir = path.resolve(settings.categoriesUploadPath + '/' + id); + fse.emptyDirSync(dir); + this.updateCategory(id, { image: '' }); + } + + uploadCategoryImage(req, res) { + let categoryId = req.params.id; + let form = new formidable.IncomingForm(), + file_name = null, + file_size = 0; + + form + .on('fileBegin', (name, file) => { + // Emitted whenever a field / value pair has been received. + let dir = path.resolve( + settings.categoriesUploadPath + '/' + categoryId + ); + fse.emptyDirSync(dir); + file.name = utils.getCorrectFileName(file.name); + file.path = dir + '/' + file.name; + }) + .on('file', function(field, file) { + // every time a file has been uploaded successfully, + file_name = file.name; + file_size = file.size; + }) + .on('error', err => { + res.status(500).send(this.getErrorMessage(err)); + }) + .on('end', () => { + //Emitted when the entire request has been received, and all contained files have finished flushing to disk. + if (file_name) { + this.updateCategory(categoryId, { image: file_name }); + res.send({ file: file_name, size: file_size }); + } else { + res + .status(400) + .send(this.getErrorMessage('Required fields are missing')); + } + }); + + form.parse(req); + } +} + +export default new ProductCategoriesService(); diff --git a/src/api/server/services/products/products.js b/src/api/server/services/products/products.js new file mode 100755 index 0000000..d8419ec --- /dev/null +++ b/src/api/server/services/products/products.js @@ -0,0 +1,1095 @@ +import { ObjectID } from 'mongodb'; +import path from 'path'; +import url from 'url'; +import fse from 'fs-extra'; +import settings from '../../lib/settings'; +import { db } from '../../lib/mongo'; +import utils from '../../lib/utils'; +import parse from '../../lib/parse'; +import CategoriesService from './productCategories'; +import SettingsService from '../settings/settings'; + +class ProductsService { + constructor() {} + + async getProducts(params = {}) { + const categories = await CategoriesService.getCategories({ + fields: 'parent_id' + }); + const fieldsArray = this.getArrayFromCSV(params.fields); + const limit = parse.getNumberIfPositive(params.limit) || 1000; + const offset = parse.getNumberIfPositive(params.offset) || 0; + const projectQuery = this.getProjectQuery(fieldsArray); + const sortQuery = this.getSortQuery(params); // todo: validate every sort field + const matchQuery = this.getMatchQuery(params, categories); + const matchTextQuery = this.getMatchTextQuery(params); + const itemsAggregation = []; + + // $match with $text is only allowed as the first pipeline stage" + if (matchTextQuery) { + itemsAggregation.push({ $match: matchTextQuery }); + } + itemsAggregation.push({ $project: projectQuery }); + itemsAggregation.push({ $match: matchQuery }); + if (sortQuery) { + itemsAggregation.push({ $sort: sortQuery }); + } + itemsAggregation.push({ $skip: offset }); + itemsAggregation.push({ $limit: limit }); + itemsAggregation.push({ + $lookup: { + from: 'productCategories', + localField: 'category_id', + foreignField: '_id', + as: 'categories' + } + }); + itemsAggregation.push({ + $project: { + 'categories.description': 0, + 'categories.meta_description': 0, + 'categories._id': 0, + 'categories.date_created': 0, + 'categories.date_updated': 0, + 'categories.image': 0, + 'categories.meta_title': 0, + 'categories.enabled': 0, + 'categories.sort': 0, + 'categories.parent_id': 0, + 'categories.position': 0 + } + }); + + const [ + itemsResult, + countResult, + minMaxPriceResult, + allAttributesResult, + attributesResult, + generalSettings + ] = await Promise.all([ + db + .collection('products') + .aggregate(itemsAggregation) + .toArray(), + this.getCountIfNeeded(params, matchQuery, matchTextQuery, projectQuery), + this.getMinMaxPriceIfNeeded( + params, + categories, + matchTextQuery, + projectQuery + ), + this.getAllAttributesIfNeeded( + params, + categories, + matchTextQuery, + projectQuery + ), + this.getAttributesIfNeeded( + params, + categories, + matchTextQuery, + projectQuery + ), + SettingsService.getSettings() + ]); + + const domain = generalSettings.domain || ''; + const ids = this.getArrayFromCSV(parse.getString(params.ids)); + const sku = this.getArrayFromCSV(parse.getString(params.sku)); + + let items = itemsResult.map(item => this.changeProperties(item, domain)); + items = this.sortItemsByArrayOfIdsIfNeed(items, ids, sortQuery); + items = this.sortItemsByArrayOfSkuIfNeed(items, sku, sortQuery); + items = items.filter(item => !!item); + + let total_count = 0; + let min_price = 0; + let max_price = 0; + + if (countResult && countResult.length === 1) { + total_count = countResult[0].count; + } + + if (minMaxPriceResult && minMaxPriceResult.length === 1) { + min_price = minMaxPriceResult[0].min_price || 0; + max_price = minMaxPriceResult[0].max_price || 0; + } + + let attributes = []; + if (allAttributesResult) { + attributes = this.getOrganizedAttributes( + allAttributesResult, + attributesResult, + params + ); + } + + return { + price: { + min: min_price, + max: max_price + }, + attributes: attributes, + total_count: total_count, + has_more: offset + items.length < total_count, + data: items + }; + } + + sortItemsByArrayOfIdsIfNeed(items, arrayOfIds, sortQuery) { + return arrayOfIds && + arrayOfIds.length > 0 && + sortQuery === null && + items && + items.length > 0 + ? arrayOfIds.map(id => items.find(item => item.id === id)) + : items; + } + + sortItemsByArrayOfSkuIfNeed(items, arrayOfSku, sortQuery) { + return arrayOfSku && + arrayOfSku.length > 0 && + sortQuery === null && + items && + items.length > 0 + ? arrayOfSku.map(sku => items.find(item => item.sku === sku)) + : items; + } + + getOrganizedAttributes( + allAttributesResult, + filteredAttributesResult, + params + ) { + const uniqueAttributesName = [ + ...new Set(allAttributesResult.map(a => a._id.name)) + ]; + + return uniqueAttributesName.sort().map(attributeName => ({ + name: attributeName, + values: allAttributesResult + .filter(b => b._id.name === attributeName) + .sort( + (a, b) => + a._id.value > b._id.value ? 1 : b._id.value > a._id.value ? -1 : 0 + ) + .map(b => ({ + name: b._id.value, + checked: + params[`attributes.${b._id.name}`] && + params[`attributes.${b._id.name}`].includes(b._id.value) + ? true + : false, + // total: b.count, + count: this.getAttributeCount( + filteredAttributesResult, + b._id.name, + b._id.value + ) + })) + })); + } + + getAttributeCount(attributesArray, attributeName, attributeValue) { + const attribute = attributesArray.find( + a => a._id.name === attributeName && a._id.value === attributeValue + ); + return attribute ? attribute.count : 0; + } + + getCountIfNeeded(params, matchQuery, matchTextQuery, projectQuery) { + // get total count + // not for product details or ids + if (!params.ids) { + const aggregation = []; + if (matchTextQuery) { + aggregation.push({ $match: matchTextQuery }); + } + aggregation.push({ $project: projectQuery }); + aggregation.push({ $match: matchQuery }); + aggregation.push({ $group: { _id: null, count: { $sum: 1 } } }); + return db + .collection('products') + .aggregate(aggregation) + .toArray(); + } else { + return null; + } + } + + getMinMaxPriceIfNeeded(params, categories, matchTextQuery, projectQuery) { + // get min max price without filter by price + // not for product details or ids + if (!params.ids) { + const minMaxPriceMatchQuery = this.getMatchQuery( + params, + categories, + false, + false + ); + + const aggregation = []; + if (matchTextQuery) { + aggregation.push({ $match: matchTextQuery }); + } + aggregation.push({ $project: projectQuery }); + aggregation.push({ $match: minMaxPriceMatchQuery }); + aggregation.push({ + $group: { + _id: null, + min_price: { $min: '$price' }, + max_price: { $max: '$price' } + } + }); + return db + .collection('products') + .aggregate(aggregation) + .toArray(); + } else { + return null; + } + } + + getAllAttributesIfNeeded(params, categories, matchTextQuery, projectQuery) { + // get attributes with counts without filter by attributes + // only for category + if (params.category_id) { + const attributesMatchQuery = this.getMatchQuery( + params, + categories, + false, + false + ); + + const aggregation = []; + if (matchTextQuery) { + aggregation.push({ $match: matchTextQuery }); + } + aggregation.push({ $project: projectQuery }); + aggregation.push({ $match: attributesMatchQuery }); + aggregation.push({ $unwind: '$attributes' }); + aggregation.push({ $group: { _id: '$attributes', count: { $sum: 1 } } }); + return db + .collection('products') + .aggregate(aggregation) + .toArray(); + } else { + return null; + } + } + + getAttributesIfNeeded(params, categories, matchTextQuery, projectQuery) { + // get attributes with counts without filter by attributes + // only for category + if (params.category_id) { + const attributesMatchQuery = this.getMatchQuery( + params, + categories, + false, + true + ); + + const aggregation = []; + if (matchTextQuery) { + aggregation.push({ $match: matchTextQuery }); + } + aggregation.push({ $project: projectQuery }); + aggregation.push({ $match: attributesMatchQuery }); + aggregation.push({ $unwind: '$attributes' }); + aggregation.push({ $group: { _id: '$attributes', count: { $sum: 1 } } }); + return db + .collection('products') + .aggregate(aggregation) + .toArray(); + } else { + return null; + } + } + + getSortQuery({ sort, search }) { + const isSearchUsed = + search && + search.length > 0 && + search !== 'null' && + search !== 'undefined'; + if (sort === 'search' && isSearchUsed) { + return { score: { $meta: 'textScore' } }; + } else if (sort && sort.length > 0) { + const fields = sort.split(','); + return Object.assign( + ...fields.map(field => ({ + [field.startsWith('-') ? field.slice(1) : field]: field.startsWith( + '-' + ) + ? -1 + : 1 + })) + ); + } else { + return null; + } + } + + getProjectQuery(fieldsArray) { + let salePrice = '$sale_price'; + let regularPrice = '$regular_price'; + let costPrice = '$cost_price'; + + let project = { + category_ids: 1, + related_product_ids: 1, + enabled: 1, + discontinued: 1, + date_created: 1, + date_updated: 1, + cost_price: costPrice, + regular_price: regularPrice, + sale_price: salePrice, + date_sale_from: 1, + date_sale_to: 1, + images: 1, + prices: 1, + quantity_inc: 1, + quantity_min: 1, + meta_description: 1, + meta_title: 1, + name: 1, + description: 1, + sku: 1, + code: 1, + tax_class: 1, + position: 1, + tags: 1, + options: 1, + variants: 1, + weight: 1, + dimensions: 1, + attributes: 1, + date_stock_expected: 1, + stock_tracking: 1, + stock_preorder: 1, + stock_backorder: 1, + stock_quantity: 1, + on_sale: { + $and: [ + { + $lt: [new Date(), '$date_sale_to'] + }, + { + $gt: [new Date(), '$date_sale_from'] + } + ] + }, + variable: { + $gt: [ + { + $size: { $ifNull: ['$variants', []] } + }, + 0 + ] + }, + price: { + $cond: { + if: { + $and: [ + { + $lt: [new Date(), '$date_sale_to'] + }, + { + $gt: [new Date(), '$date_sale_from'] + }, + { + $gt: ['$sale_price', 0] + } + ] + }, + then: salePrice, + else: regularPrice + } + }, + stock_status: { + $cond: { + if: { + $eq: ['$discontinued', true] + }, + then: 'discontinued', + else: { + $cond: { + if: { + $gt: ['$stock_quantity', 0] + }, + then: 'available', + else: { + $cond: { + if: { + $eq: ['$stock_backorder', true] + }, + then: 'backorder', + else: { + $cond: { + if: { + $eq: ['$stock_preorder', true] + }, + then: 'preorder', + else: 'out_of_stock' + } + } + } + } + } + } + } + }, + url: { $literal: '' }, + path: { $literal: '' }, + category_name: { $literal: '' }, + category_slug: { $literal: '' } + }; + + if (fieldsArray && fieldsArray.length > 0) { + project = this.getProjectFilteredByFields(project, fieldsArray); + } + + // required fields + project._id = 0; + project.id = '$_id'; + project.category_id = 1; + project.slug = 1; + + return project; + } + + getArrayFromCSV(fields) { + return fields && fields.length > 0 ? fields.split(',') : []; + } + + getProjectFilteredByFields(project, fieldsArray) { + return Object.assign(...fieldsArray.map(key => ({ [key]: project[key] }))); + } + + getMatchTextQuery({ search }) { + if ( + search && + search.length > 0 && + search !== 'null' && + search !== 'undefined' + ) { + return { + $or: [{ sku: new RegExp(search, 'i') }, { $text: { $search: search } }] + }; + } else { + return null; + } + } + + getMatchAttributesQuery(params) { + let attributesArray = Object.keys(params) + .filter(paramName => paramName.startsWith('attributes.')) + .map(paramName => { + const paramValue = params[paramName]; + const paramValueArray = Array.isArray(paramValue) + ? paramValue + : [paramValue]; + + return { + name: paramName.replace('attributes.', ''), + values: paramValueArray + }; + }); + + return attributesArray; + } + + getMatchQuery(params, categories, useAttributes = true, usePrice = true) { + let { + category_id, + enabled, + discontinued, + on_sale, + stock_status, + price_from, + price_to, + sku, + ids, + tags + } = params; + + // parse values + category_id = parse.getObjectIDIfValid(category_id); + enabled = parse.getBooleanIfValid(enabled); + discontinued = parse.getBooleanIfValid(discontinued); + on_sale = parse.getBooleanIfValid(on_sale); + price_from = parse.getNumberIfPositive(price_from); + price_to = parse.getNumberIfPositive(price_to); + ids = parse.getString(ids); + tags = parse.getString(tags); + + let queries = []; + const currentDate = new Date(); + + if (category_id !== null) { + let categoryChildren = []; + CategoriesService.findAllChildren( + categories, + category_id, + categoryChildren + ); + queries.push({ + $or: [ + { + category_id: { $in: categoryChildren } + }, + { + category_ids: category_id + } + ] + }); + } + + if (enabled !== null) { + queries.push({ + enabled: enabled + }); + } + + if (discontinued !== null) { + queries.push({ + discontinued: discontinued + }); + } + + if (on_sale !== null) { + queries.push({ + on_sale: on_sale + }); + } + + if (usePrice) { + if (price_from !== null && price_from > 0) { + queries.push({ + price: { $gte: price_from } + }); + } + + if (price_to !== null && price_to > 0) { + queries.push({ + price: { $lte: price_to } + }); + } + } + + if (stock_status && stock_status.length > 0) { + queries.push({ + stock_status: stock_status + }); + } + + if (ids && ids.length > 0) { + const idsArray = ids.split(','); + let objectIDs = []; + for (const id of idsArray) { + if (ObjectID.isValid(id)) { + objectIDs.push(new ObjectID(id)); + } + } + queries.push({ + id: { $in: objectIDs } + }); + } + + if (sku && sku.length > 0) { + if (sku.includes(',')) { + // multiple values + const skus = sku.split(','); + queries.push({ + sku: { $in: skus } + }); + } else { + // single value + queries.push({ + sku: sku + }); + } + } + + if (tags && tags.length > 0) { + queries.push({ + tags: tags + }); + } + + if (useAttributes) { + const attributesArray = this.getMatchAttributesQuery(params); + if (attributesArray && attributesArray.length > 0) { + const matchesArray = attributesArray.map(attribute => ({ + $elemMatch: { name: attribute.name, value: { $in: attribute.values } } + })); + queries.push({ + attributes: { $all: matchesArray } + }); + } + } + + let matchQuery = {}; + if (queries.length === 1) { + matchQuery = queries[0]; + } else if (queries.length > 1) { + matchQuery = { + $and: queries + }; + } + + return matchQuery; + } + + getSingleProduct(id) { + if (!ObjectID.isValid(id)) { + return Promise.reject('Invalid identifier'); + } + return this.getProducts({ ids: id, limit: 1 }).then( + products => (products.data.length > 0 ? products.data[0] : {}) + ); + } + + addProduct(data) { + return this.getValidDocumentForInsert(data) + .then(dataToInsert => + db.collection('products').insertMany([dataToInsert]) + ) + .then(res => this.getSingleProduct(res.ops[0]._id.toString())); + } + + updateProduct(id, data) { + if (!ObjectID.isValid(id)) { + return Promise.reject('Invalid identifier'); + } + const productObjectID = new ObjectID(id); + + return this.getValidDocumentForUpdate(id, data) + .then(dataToSet => + db + .collection('products') + .updateOne({ _id: productObjectID }, { $set: dataToSet }) + ) + .then(res => (res.modifiedCount > 0 ? this.getSingleProduct(id) : null)); + } + + deleteProduct(productId) { + if (!ObjectID.isValid(productId)) { + return Promise.reject('Invalid identifier'); + } + const productObjectID = new ObjectID(productId); + // 1. delete Product + return db + .collection('products') + .deleteOne({ _id: productObjectID }) + .then(deleteResponse => { + if (deleteResponse.deletedCount > 0) { + // 2. delete directory with images + let deleteDir = path.resolve( + settings.productsUploadPath + '/' + productId + ); + fse.remove(deleteDir, err => {}); + } + return deleteResponse.deletedCount > 0; + }); + } + + getValidDocumentForInsert(data) { + // Allow empty product to create draft + + let product = { + date_created: new Date(), + date_updated: null, + images: [], + dimensions: { + length: 0, + width: 0, + height: 0 + } + }; + + product.name = parse.getString(data.name); + product.description = parse.getString(data.description); + product.meta_description = parse.getString(data.meta_description); + product.meta_title = parse.getString(data.meta_title); + product.tags = parse.getArrayIfValid(data.tags) || []; + product.attributes = this.getValidAttributesArray(data.attributes); + product.enabled = parse.getBooleanIfValid(data.enabled, true); + product.discontinued = parse.getBooleanIfValid(data.discontinued, false); + product.slug = parse.getString(data.slug); + product.sku = parse.getString(data.sku); + product.code = parse.getString(data.code); + product.tax_class = parse.getString(data.tax_class); + product.related_product_ids = this.getArrayOfObjectID( + data.related_product_ids + ); + product.prices = parse.getArrayIfValid(data.prices) || []; + product.cost_price = parse.getNumberIfPositive(data.cost_price) || 0; + product.regular_price = parse.getNumberIfPositive(data.regular_price) || 0; + product.sale_price = parse.getNumberIfPositive(data.sale_price) || 0; + product.quantity_inc = parse.getNumberIfPositive(data.quantity_inc) || 1; + product.quantity_min = parse.getNumberIfPositive(data.quantity_min) || 1; + product.weight = parse.getNumberIfPositive(data.weight) || 0; + product.stock_quantity = + parse.getNumberIfPositive(data.stock_quantity) || 0; + product.position = parse.getNumberIfValid(data.position); + product.date_stock_expected = parse.getDateIfValid( + data.date_stock_expected + ); + product.date_sale_from = parse.getDateIfValid(data.date_sale_from); + product.date_sale_to = parse.getDateIfValid(data.date_sale_to); + product.stock_tracking = parse.getBooleanIfValid( + data.stock_tracking, + false + ); + product.stock_preorder = parse.getBooleanIfValid( + data.stock_preorder, + false + ); + product.stock_backorder = parse.getBooleanIfValid( + data.stock_backorder, + false + ); + product.category_id = parse.getObjectIDIfValid(data.category_id); + product.category_ids = parse.getArrayOfObjectID(data.category_ids); + + if (data.dimensions) { + product.dimensions = data.dimensions; + } + + if (product.slug.length === 0) { + product.slug = product.name; + } + + return this.setAvailableSlug(product).then(product => + this.setAvailableSku(product) + ); + } + + getValidDocumentForUpdate(id, data) { + if (Object.keys(data).length === 0) { + throw new Error('Required fields are missing'); + } + + let product = { + date_updated: new Date() + }; + + if (data.name !== undefined) { + product.name = parse.getString(data.name); + } + + if (data.description !== undefined) { + product.description = parse.getString(data.description); + } + + if (data.meta_description !== undefined) { + product.meta_description = parse.getString(data.meta_description); + } + + if (data.meta_title !== undefined) { + product.meta_title = parse.getString(data.meta_title); + } + + if (data.tags !== undefined) { + product.tags = parse.getArrayIfValid(data.tags) || []; + } + + if (data.attributes !== undefined) { + product.attributes = this.getValidAttributesArray(data.attributes); + } + + if (data.dimensions !== undefined) { + product.dimensions = data.dimensions; + } + + if (data.enabled !== undefined) { + product.enabled = parse.getBooleanIfValid(data.enabled, true); + } + + if (data.discontinued !== undefined) { + product.discontinued = parse.getBooleanIfValid(data.discontinued, false); + } + + if (data.slug !== undefined) { + if (data.slug === '' && product.name && product.name.length > 0) { + product.slug = product.name; + } else { + product.slug = parse.getString(data.slug); + } + } + + if (data.sku !== undefined) { + product.sku = parse.getString(data.sku); + } + + if (data.code !== undefined) { + product.code = parse.getString(data.code); + } + + if (data.tax_class !== undefined) { + product.tax_class = parse.getString(data.tax_class); + } + + if (data.related_product_ids !== undefined) { + product.related_product_ids = this.getArrayOfObjectID( + data.related_product_ids + ); + } + + if (data.prices !== undefined) { + product.prices = parse.getArrayIfValid(data.prices) || []; + } + + if (data.cost_price !== undefined) { + product.cost_price = parse.getNumberIfPositive(data.cost_price) || 0; + } + + if (data.regular_price !== undefined) { + product.regular_price = + parse.getNumberIfPositive(data.regular_price) || 0; + } + + if (data.sale_price !== undefined) { + product.sale_price = parse.getNumberIfPositive(data.sale_price) || 0; + } + + if (data.quantity_inc !== undefined) { + product.quantity_inc = parse.getNumberIfPositive(data.quantity_inc) || 1; + } + + if (data.quantity_min !== undefined) { + product.quantity_min = parse.getNumberIfPositive(data.quantity_min) || 1; + } + + if (data.weight !== undefined) { + product.weight = parse.getNumberIfPositive(data.weight) || 0; + } + + if (data.stock_quantity !== undefined) { + product.stock_quantity = + parse.getNumberIfPositive(data.stock_quantity) || 0; + } + + if (data.position !== undefined) { + product.position = parse.getNumberIfValid(data.position); + } + + if (data.date_stock_expected !== undefined) { + product.date_stock_expected = parse.getDateIfValid( + data.date_stock_expected + ); + } + + if (data.date_sale_from !== undefined) { + product.date_sale_from = parse.getDateIfValid(data.date_sale_from); + } + + if (data.date_sale_to !== undefined) { + product.date_sale_to = parse.getDateIfValid(data.date_sale_to); + } + + if (data.stock_tracking !== undefined) { + product.stock_tracking = parse.getBooleanIfValid( + data.stock_tracking, + false + ); + } + + if (data.stock_preorder !== undefined) { + product.stock_preorder = parse.getBooleanIfValid( + data.stock_preorder, + false + ); + } + + if (data.stock_backorder !== undefined) { + product.stock_backorder = parse.getBooleanIfValid( + data.stock_backorder, + false + ); + } + + if (data.category_id !== undefined) { + product.category_id = parse.getObjectIDIfValid(data.category_id); + } + + if (data.category_ids !== undefined) { + product.category_ids = parse.getArrayOfObjectID(data.category_ids); + } + + return this.setAvailableSlug(product, id).then(product => + this.setAvailableSku(product, id) + ); + } + + getArrayOfObjectID(array) { + if (array && Array.isArray(array)) { + return array.map(item => parse.getObjectIDIfValid(item)); + } else { + return []; + } + } + + getValidAttributesArray(attributes) { + if (attributes && Array.isArray(attributes)) { + return attributes + .filter( + item => + item.name && item.name !== '' && item.value && item.value !== '' + ) + .map(item => ({ + name: parse.getString(item.name), + value: parse.getString(item.value) + })); + } else { + return []; + } + } + + getSortedImagesWithUrls(item, domain) { + if (item.images && item.images.length > 0) { + return item.images + .map(image => { + image.url = this.getImageUrl(domain, item.id, image.filename || ''); + return image; + }) + .sort((a, b) => a.position - b.position); + } else { + return item.images; + } + } + + getImageUrl(domain, productId, imageFileName) { + return url.resolve( + domain, + `${settings.productsUploadUrl}/${productId}/${imageFileName}` + ); + } + + changeProperties(item, domain) { + if (item) { + if (item.id) { + item.id = item.id.toString(); + } + + item.images = this.getSortedImagesWithUrls(item, domain); + + if (item.category_id) { + item.category_id = item.category_id.toString(); + + if (item.categories && item.categories.length > 0) { + const category = item.categories[0]; + if (category) { + if (item.category_name === '') { + item.category_name = category.name; + } + + if (item.category_slug === '') { + item.category_slug = category.slug; + } + + const categorySlug = category.slug || ''; + const productSlug = item.slug || ''; + + if (item.url === '') { + item.url = url.resolve(domain, `/${categorySlug}/${productSlug}`); + } + + if (item.path === '') { + item.path = `/${categorySlug}/${productSlug}`; + } + } + } + } + item.categories = undefined; + } + + return item; + } + + isSkuExists(sku, productId) { + let filter = { + sku: sku + }; + + if (productId && ObjectID.isValid(productId)) { + filter._id = { $ne: new ObjectID(productId) }; + } + + return db + .collection('products') + .count(filter) + .then(count => count > 0); + } + + setAvailableSku(product, productId) { + // SKU can be empty + if (product.sku && product.sku.length > 0) { + let newSku = product.sku; + let filter = {}; + if (productId && ObjectID.isValid(productId)) { + filter._id = { $ne: new ObjectID(productId) }; + } + + return db + .collection('products') + .find(filter) + .project({ sku: 1 }) + .toArray() + .then(products => { + while (products.find(p => p.sku === newSku)) { + newSku += '-2'; + } + product.sku = newSku; + return product; + }); + } else { + return Promise.resolve(product); + } + } + + isSlugExists(slug, productId) { + let filter = { + slug: utils.cleanSlug(slug) + }; + + if (productId && ObjectID.isValid(productId)) { + filter._id = { $ne: new ObjectID(productId) }; + } + + return db + .collection('products') + .count(filter) + .then(count => count > 0); + } + + setAvailableSlug(product, productId) { + if (product.slug && product.slug.length > 0) { + let newSlug = utils.cleanSlug(product.slug); + let filter = {}; + if (productId && ObjectID.isValid(productId)) { + filter._id = { $ne: new ObjectID(productId) }; + } + + return db + .collection('products') + .find(filter) + .project({ slug: 1 }) + .toArray() + .then(products => { + while (products.find(p => p.slug === newSlug)) { + newSlug += '-2'; + } + product.slug = newSlug; + return product; + }); + } else { + return Promise.resolve(product); + } + } +} + +export default new ProductsService(); diff --git a/src/api/server/services/products/stock.js b/src/api/server/services/products/stock.js new file mode 100755 index 0000000..6bf9a55 --- /dev/null +++ b/src/api/server/services/products/stock.js @@ -0,0 +1,129 @@ +import { ObjectID } from 'mongodb'; +import { db } from '../../lib/mongo'; +import ProductsService from './products'; +import ProductVariantsService from './variants'; + +class ProductStockService { + async handleOrderCheckout(orderId) { + const order = await this.getOrder(orderId); + if (order && order.items.length > 0) { + for (const item of order.items) { + await this.decrementStockQuantity( + item.product_id, + item.variant_id, + item.quantity + ); + } + } + } + + async handleCancelOrder(orderId) { + const order = await this.getOrder(orderId); + if (order && order.items.length > 0) { + for (const item of order.items) { + await this.incrementStockQuantity( + item.product_id, + item.variant_id, + item.quantity + ); + } + } + } + + async handleAddOrderItem(orderId, itemId) { + const item = await this.getOrderItem(orderId, itemId); + if (item) { + await this.decrementStockQuantity( + item.product_id, + item.variant_id, + item.quantity + ); + } + } + + async handleDeleteOrderItem(orderId, itemId) { + const item = await this.getOrderItem(orderId, itemId); + if (item) { + await this.incrementStockQuantity( + item.product_id, + item.variant_id, + item.quantity + ); + } + } + + async incrementStockQuantity(productId, variantId, quantity) { + await this.changeStockQuantity(productId, variantId, quantity); + } + + async decrementStockQuantity(productId, variantId, quantity) { + await this.changeStockQuantity(productId, variantId, quantity * -1); + } + + async changeStockQuantity(productId, variantId, quantity) { + const product = await ProductsService.getSingleProduct(productId); + if (product && this.isStockTrackingEnabled(product)) { + // change product stock quantity + const productQuantity = product.stock_quantity || 0; + const newProductQuantity = productQuantity + quantity; + await ProductsService.updateProduct(productId, { + stock_quantity: newProductQuantity + }); + + if (this.isVariant(variantId)) { + // change variant stock quantity + const variantQuantity = this.getVariantQuantityFromProduct( + product, + variantId + ); + const newVariantQuantity = variantQuantity + quantity; + await ProductVariantsService.updateVariant(productId, variantId, { + stock_quantity: newVariantQuantity + }); + } + } + } + + getVariantQuantityFromProduct(product, variantId) { + const variants = product.variants; + if (variants && variants.length > 0) { + const variant = variants.find( + v => v.id.toString() === variantId.toString() + ); + if (variant) { + return variant.stock_quantity || 0; + } + } + + return 0; + } + + isStockTrackingEnabled(product) { + return product.stock_tracking === true; + } + + isVariant(variantId) { + return variantId && variantId !== ''; + } + + async getOrder(orderId) { + const filter = { + _id: new ObjectID(orderId), + draft: false + }; + + const order = await db.collection('orders').findOne(filter); + return order; + } + + async getOrderItem(orderId, itemId) { + const order = await this.getOrder(orderId); + if (order && order.items.length > 0) { + return order.items.find(item => item.id.toString() === itemId.toString()); + } else { + return null; + } + } +} + +export default new ProductStockService(); diff --git a/src/api/server/services/products/variants.js b/src/api/server/services/products/variants.js new file mode 100755 index 0000000..276bab3 --- /dev/null +++ b/src/api/server/services/products/variants.js @@ -0,0 +1,211 @@ +import { ObjectID } from 'mongodb'; +import { db } from '../../lib/mongo'; +import parse from '../../lib/parse'; + +class ProductVariantsService { + constructor() {} + + getVariants(productId) { + if (!ObjectID.isValid(productId)) { + return Promise.reject('Invalid identifier'); + } + + let productObjectID = new ObjectID(productId); + return db + .collection('products') + .findOne({ _id: productObjectID }, { fields: { variants: 1 } }) + .then(product => product.variants || []); + } + + deleteVariant(productId, variantId) { + if (!ObjectID.isValid(productId) || !ObjectID.isValid(variantId)) { + return Promise.reject('Invalid identifier'); + } + let productObjectID = new ObjectID(productId); + let variantObjectID = new ObjectID(variantId); + + return db + .collection('products') + .updateOne( + { + _id: productObjectID + }, + { + $pull: { + variants: { + id: variantObjectID + } + } + } + ) + .then(res => this.getVariants(productId)); + } + + addVariant(productId, data) { + if (!ObjectID.isValid(productId)) { + return Promise.reject('Invalid identifier'); + } + let productObjectID = new ObjectID(productId); + + const variantData = this.getValidDocumentForInsert(data); + + return db + .collection('products') + .updateOne({ _id: productObjectID }, { $push: { variants: variantData } }) + .then(res => this.getVariants(productId)); + } + + updateVariant(productId, variantId, data) { + if (!ObjectID.isValid(productId) || !ObjectID.isValid(variantId)) { + return Promise.reject('Invalid identifier'); + } + let productObjectID = new ObjectID(productId); + let variantObjectID = new ObjectID(variantId); + + const variantData = this.getValidDocumentForUpdate(data); + + return db + .collection('products') + .updateOne( + { + _id: productObjectID, + 'variants.id': variantObjectID + }, + { $set: variantData } + ) + .then(res => this.getVariants(productId)); + } + + getValidDocumentForInsert(data) { + let variant = { + id: new ObjectID(), + sku: parse.getString(data.sku), + price: parse.getNumberIfPositive(data.price) || 0, + stock_quantity: parse.getNumberIfPositive(data.stock_quantity) || 0, + weight: parse.getNumberIfPositive(data.weight) || 0, + options: [] + }; + + return variant; + } + + getValidDocumentForUpdate(data) { + if (Object.keys(data).length === 0) { + return new Error('Required fields are missing'); + } + + let variant = {}; + + if (data.sku !== undefined) { + variant['variants.$.sku'] = parse.getString(data.sku); + } + + if (data.price !== undefined) { + variant['variants.$.price'] = parse.getNumberIfPositive(data.price) || 0; + } + + if (data.stock_quantity !== undefined) { + variant['variants.$.stock_quantity'] = + parse.getNumberIfPositive(data.stock_quantity) || 0; + } + + if (data.weight !== undefined) { + variant['variants.$.weight'] = + parse.getNumberIfPositive(data.weight) || 0; + } + + return variant; + } + + getVariantOptions(productId, variantId) { + let productObjectID = new ObjectID(productId); + + return db + .collection('products') + .findOne({ _id: productObjectID }, { fields: { variants: 1 } }) + .then(product => (product && product.variants ? product.variants : null)) + .then( + variants => + variants && variants.length > 0 + ? variants.find(variant => variant.id.toString() === variantId) + : null + ) + .then( + variant => + variant && variant.options.length > 0 ? variant.options : [] + ); + } + + getModifiedVariantOptions(productId, variantId, optionId, valueId) { + return this.getVariantOptions(productId, variantId).then(options => { + if (options && options.length > 0) { + const optionToChange = options.find( + option => option.option_id.toString() === optionId + ); + + if (optionToChange === undefined) { + // if option not exists => add new option + options.push({ + option_id: new ObjectID(optionId), + value_id: new ObjectID(valueId) + }); + } else { + // if option exists => set new valueId + + if (optionToChange.value_id.toString() === valueId) { + // don't save same value + return option; + } + + options = options.map(option => { + if (option.option_id.toString() === optionId) { + option.value_id = new ObjectID(valueId); + return option; + } else { + return option; + } + }); + } + } else { + options = []; + options.push({ + option_id: new ObjectID(optionId), + value_id: new ObjectID(valueId) + }); + } + + return options; + }); + } + + setVariantOption(productId, variantId, data) { + if ( + !ObjectID.isValid(productId) || + !ObjectID.isValid(variantId) || + !ObjectID.isValid(data.option_id) || + !ObjectID.isValid(data.value_id) + ) { + return Promise.reject('Invalid identifier'); + } + let productObjectID = new ObjectID(productId); + let variantObjectID = new ObjectID(variantId); + + return this.getModifiedVariantOptions( + productId, + variantId, + data.option_id, + data.value_id + ) + .then(options => + db + .collection('products') + .updateOne( + { _id: productObjectID, 'variants.id': variantObjectID }, + { $set: { 'variants.$.options': options } } + ) + ) + .then(res => this.getVariants(productId)); + } +} + +export default new ProductVariantsService(); diff --git a/src/api/server/services/redirects.js b/src/api/server/services/redirects.js new file mode 100755 index 0000000..1e4e1b9 --- /dev/null +++ b/src/api/server/services/redirects.js @@ -0,0 +1,131 @@ +import { ObjectID } from 'mongodb'; +import lruCache from 'lru-cache'; +import { db } from '../lib/mongo'; +import utils from '../lib/utils'; +import parse from '../lib/parse'; + +const cache = lruCache({ + max: 10000, + maxAge: 1000 * 60 * 60 * 24 // 24h +}); + +const REDIRECTS_CACHE_KEY = 'redirects'; + +class RedirectsService { + constructor() {} + + getRedirects() { + const redirectsFromCache = cache.get(REDIRECTS_CACHE_KEY); + + if (redirectsFromCache) { + return Promise.resolve(redirectsFromCache); + } else { + return db + .collection('redirects') + .find() + .toArray() + .then(items => items.map(item => this.changeProperties(item))) + .then(items => { + cache.set(REDIRECTS_CACHE_KEY, items); + return items; + }); + } + } + + getSingleRedirect(id) { + if (!ObjectID.isValid(id)) { + return Promise.reject('Invalid identifier'); + } + let redirectObjectID = new ObjectID(id); + + return db + .collection('redirects') + .findOne({ _id: redirectObjectID }) + .then(item => this.changeProperties(item)); + } + + addRedirect(data) { + const redirect = this.getValidDocumentForInsert(data); + return db + .collection('redirects') + .insertMany([redirect]) + .then(res => { + cache.del(REDIRECTS_CACHE_KEY); + return this.getSingleRedirect(res.ops[0]._id.toString()); + }); + } + + updateRedirect(id, data) { + if (!ObjectID.isValid(id)) { + return Promise.reject('Invalid identifier'); + } + const redirectObjectID = new ObjectID(id); + const redirect = this.getValidDocumentForUpdate(id, data); + + return db + .collection('redirects') + .updateOne( + { + _id: redirectObjectID + }, + { $set: redirect } + ) + .then(res => { + cache.del(REDIRECTS_CACHE_KEY); + return this.getSingleRedirect(id); + }); + } + + deleteRedirect(id) { + if (!ObjectID.isValid(id)) { + return Promise.reject('Invalid identifier'); + } + const redirectObjectID = new ObjectID(id); + return db + .collection('redirects') + .deleteOne({ _id: redirectObjectID }) + .then(deleteResponse => { + cache.del(REDIRECTS_CACHE_KEY); + return deleteResponse.deletedCount > 0; + }); + } + + getValidDocumentForInsert(data) { + let redirect = { + from: parse.getString(data.from), + to: parse.getString(data.to), + status: 301 + }; + + return redirect; + } + + getValidDocumentForUpdate(id, data) { + if (Object.keys(data).length === 0) { + return new Error('Required fields are missing'); + } + + let redirect = {}; + + if (data.from !== undefined) { + redirect.from = parse.getString(data.from); + } + + if (data.to !== undefined) { + redirect.to = parse.getString(data.to); + } + + return redirect; + } + + changeProperties(item) { + if (item) { + item.id = item._id.toString(); + delete item._id; + } + + return item; + } +} + +export default new RedirectsService(); diff --git a/src/api/server/services/security/tokens.js b/src/api/server/services/security/tokens.js new file mode 100755 index 0000000..bfc532e --- /dev/null +++ b/src/api/server/services/security/tokens.js @@ -0,0 +1,324 @@ +import { ObjectID } from 'mongodb'; +import url from 'url'; +import jwt from 'jsonwebtoken'; +import moment from 'moment'; +import uaParser from 'ua-parser-js'; +import handlebars from 'handlebars'; +import lruCache from 'lru-cache'; +import { db } from '../../lib/mongo'; +import parse from '../../lib/parse'; +import settings from '../../lib/settings'; +import mailer from '../../lib/mailer'; +import SettingsService from '../settings/settings'; + +const cache = lruCache({ + max: 10000, + maxAge: 1000 * 60 * 60 * 24 // 24h +}); + +const BLACKLIST_CACHE_KEY = 'blacklist'; + +class SecurityTokensService { + constructor() {} + + getTokens(params = {}) { + let filter = { + is_revoked: false + }; + const id = parse.getObjectIDIfValid(params.id); + if (id) { + filter._id = new ObjectID(id); + } + + const email = parse.getString(params.email).toLowerCase(); + if (email && email.length > 0) { + filter.email = email; + } + + return db + .collection('tokens') + .find(filter) + .toArray() + .then(items => items.map(item => this.changeProperties(item))); + } + + getTokensBlacklist() { + const blacklistFromCache = cache.get(BLACKLIST_CACHE_KEY); + + if (blacklistFromCache) { + return Promise.resolve(blacklistFromCache); + } else { + return db + .collection('tokens') + .find( + { + is_revoked: true + }, + { _id: 1 } + ) + .toArray() + .then(items => { + const blacklistFromDB = items.map(item => item._id.toString()); + cache.set(BLACKLIST_CACHE_KEY, blacklistFromDB); + return blacklistFromDB; + }); + } + } + + getSingleToken(id) { + if (!ObjectID.isValid(id)) { + return Promise.reject('Invalid identifier'); + } + return this.getTokens({ id: id }).then(items => { + return items.length > 0 ? items[0] : null; + }); + } + + getSingleTokenByEmail(email) { + return this.getTokens({ email }).then(items => { + return items.length > 0 ? items[0] : null; + }); + } + + addToken(data) { + return this.getValidDocumentForInsert(data) + .then(tokenData => db.collection('tokens').insertMany([tokenData])) + .then(res => this.getSingleToken(res.ops[0]._id.toString())) + .then(token => + this.getSignedToken(token).then(signedToken => { + token.token = signedToken; + return token; + }) + ); + } + + updateToken(id, data) { + if (!ObjectID.isValid(id)) { + return Promise.reject('Invalid identifier'); + } + const tokenObjectID = new ObjectID(id); + const token = this.getValidDocumentForUpdate(id, data); + + return db + .collection('tokens') + .updateOne( + { + _id: tokenObjectID + }, + { $set: token } + ) + .then(res => this.getSingleToken(id)); + } + + deleteToken(id) { + if (!ObjectID.isValid(id)) { + return Promise.reject('Invalid identifier'); + } + const tokenObjectID = new ObjectID(id); + return db + .collection('tokens') + .updateOne( + { + _id: tokenObjectID + }, + { + $set: { + is_revoked: true, + date_created: new Date() + } + } + ) + .then(res => { + cache.del(BLACKLIST_CACHE_KEY); + }); + } + + checkTokenEmailUnique(email) { + if (email && email.length > 0) { + return db + .collection('tokens') + .count({ email: email, is_revoked: false }) + .then( + count => + count === 0 ? email : Promise.reject('Token email must be unique') + ); + } else { + return Promise.resolve(email); + } + } + + getValidDocumentForInsert(data) { + const email = parse.getString(data.email); + return this.checkTokenEmailUnique(email).then(email => { + let token = { + is_revoked: false, + date_created: new Date() + }; + + token.name = parse.getString(data.name); + if (email && email.length > 0) { + token.email = email.toLowerCase(); + } + token.scopes = parse.getArrayIfValid(data.scopes); + token.expiration = parse.getNumberIfPositive(data.expiration); + + return token; + }); + } + + getValidDocumentForUpdate(id, data) { + if (Object.keys(data).length === 0) { + return new Error('Required fields are missing'); + } + + let token = { + date_updated: new Date() + }; + + if (data.name !== undefined) { + token.name = parse.getString(data.name); + } + + if (data.expiration !== undefined) { + token.expiration = parse.getNumberIfPositive(data.expiration); + } + + return token; + } + + changeProperties(item) { + if (item) { + item.id = item._id.toString(); + delete item._id; + delete item.is_revoked; + } + + return item; + } + + getSignedToken(token) { + return new Promise((resolve, reject) => { + const jwtOptions = {}; + + let payload = { + scopes: token.scopes, + jti: token.id + }; + + if (token.email && token.email.length > 0) { + payload.email = token.email.toLowerCase(); + } + + if (token.expiration) { + // convert hour to sec + jwtOptions.expiresIn = token.expiration * 60 * 60; + } + + jwt.sign(payload, settings.jwtSecretKey, jwtOptions, (err, token) => { + if (err) { + reject(err); + } else { + resolve(token); + } + }); + }); + } + + getDashboardSigninUrl(email) { + return SettingsService.getSettings().then(generalSettings => + this.getSingleTokenByEmail(email).then(token => { + if (token) { + return this.getSignedToken(token).then(signedToken => { + const loginUrl = url.resolve( + generalSettings.domain, + settings.adminLoginUrl + ); + return `${loginUrl}?token=${signedToken}`; + }); + } else { + return null; + } + }) + ); + } + + getIP(req) { + let ip = req.get('x-forwarded-for') || req.ip; + + if (ip && ip.includes(', ')) { + ip = ip.split(', ')[0]; + } + + if (ip && ip.includes('::ffff:')) { + ip = ip.replace('::ffff:', ''); + } + + if (ip === '::1') { + ip = 'localhost'; + } + + return ip; + } + + getTextFromHandlebars(text, context) { + const template = handlebars.compile(text, { noEscape: true }); + return template(context); + } + + getSigninMailSubject() { + return 'New sign-in from {{from}}'; + } + + getSigninMailBody() { + return `
+ Your email address {{email}} was just used to request
a sign in email to {{domain}} dashboard. +
Click here to sign in
+ Request from +
{{requestFrom}}
+ If this was not you, you can safely ignore this email.

+ Best,
+ Cezerin Robot`; + } + + async sendDashboardSigninUrl(req) { + const email = req.body.email; + const userAgent = uaParser(req.get('user-agent')); + const country = req.get('cf-ipcountry') || ''; + const ip = this.getIP(req); + const date = moment(new Date()).format('dddd, MMMM DD, YYYY h:mm A'); + const link = await this.getDashboardSigninUrl(email); + + if (link) { + const linkObj = url.parse(link); + const domain = `${linkObj.protocol}//${linkObj.host}`; + const device = userAgent.device.vendor + ? userAgent.device.vendor + ' ' + userAgent.device.model + ', ' + : ''; + const requestFrom = `${device}${userAgent.os.name}, ${ + userAgent.browser.name + }
+ ${date}
+ IP: ${ip}
+ ${country}`; + + const message = { + to: email, + subject: this.getTextFromHandlebars(this.getSigninMailSubject(), { + from: userAgent.os.name + }), + html: this.getTextFromHandlebars(this.getSigninMailBody(), { + link, + email, + domain, + requestFrom + }) + }; + const emailSent = await mailer.send(message); + return { sent: emailSent, error: null }; + } else { + return { sent: false, error: 'Access Denied' }; + } + } +} + +export default new SecurityTokensService(); diff --git a/src/api/server/services/settings/checkoutFields.js b/src/api/server/services/settings/checkoutFields.js new file mode 100755 index 0000000..ae1e30d --- /dev/null +++ b/src/api/server/services/settings/checkoutFields.js @@ -0,0 +1,81 @@ +import { db } from '../../lib/mongo'; +import parse from '../../lib/parse'; + +class CheckoutFieldsService { + constructor() {} + + getCheckoutFields() { + return db + .collection('checkoutFields') + .find() + .toArray() + .then(fields => + fields.map(field => { + delete field._id; + return field; + }) + ); + } + + getCheckoutField(name) { + return db + .collection('checkoutFields') + .findOne({ name: name }) + .then(field => { + return this.changeProperties(field); + }); + } + + updateCheckoutField(name, data) { + const field = this.getValidDocumentForUpdate(data); + return db + .collection('checkoutFields') + .updateOne( + { name: name }, + { + $set: field + }, + { upsert: true } + ) + .then(res => this.getCheckoutField(name)); + } + + getValidDocumentForUpdate(data) { + if (Object.keys(data).length === 0) { + return new Error('Required fields are missing'); + } + + let field = {}; + + if (data.status !== undefined) { + field.status = parse.getString(data.status); + } + + if (data.label !== undefined) { + field.label = parse.getString(data.label); + } + + if (data.placeholder !== undefined) { + field.placeholder = parse.getString(data.placeholder); + } + + return field; + } + + changeProperties(field) { + if (field) { + delete field._id; + delete field.name; + } else { + return { + status: 'required', + label: '', + placeholder: '' + }; + } + + return field; + } +} + +export default new CheckoutFieldsService(); diff --git a/src/api/server/services/settings/email.js b/src/api/server/services/settings/email.js new file mode 100755 index 0000000..5c275bc --- /dev/null +++ b/src/api/server/services/settings/email.js @@ -0,0 +1,99 @@ +import { db } from '../../lib/mongo'; +import parse from '../../lib/parse'; + +class EmailSettingsService { + constructor() { + this.defaultSettings = { + host: '', + port: '', + user: '', + pass: 0, + from_name: '', + from_address: '' + }; + } + + getEmailSettings() { + return db + .collection('emailSettings') + .findOne() + .then(settings => { + return this.changeProperties(settings); + }); + } + + updateEmailSettings(data) { + const settings = this.getValidDocumentForUpdate(data); + return this.insertDefaultSettingsIfEmpty().then(() => + db + .collection('emailSettings') + .updateOne( + {}, + { + $set: settings + }, + { upsert: true } + ) + .then(res => this.getEmailSettings()) + ); + } + + insertDefaultSettingsIfEmpty() { + return db + .collection('emailSettings') + .countDocuments({}) + .then(count => { + if (count === 0) { + return db.collection('emailSettings').insertOne(this.defaultSettings); + } else { + return; + } + }); + } + + getValidDocumentForUpdate(data) { + if (Object.keys(data).length === 0) { + return new Error('Required fields are missing'); + } + + let settings = {}; + + if (data.host !== undefined) { + settings.host = parse.getString(data.host).toLowerCase(); + } + + if (data.port !== undefined) { + settings.port = parse.getNumberIfPositive(data.port); + } + + if (data.user !== undefined) { + settings.user = parse.getString(data.user); + } + + if (data.pass !== undefined) { + settings.pass = parse.getString(data.pass); + } + + if (data.from_name !== undefined) { + settings.from_name = parse.getString(data.from_name); + } + + if (data.from_address !== undefined) { + settings.from_address = parse.getString(data.from_address); + } + + return settings; + } + + changeProperties(settings) { + if (settings) { + delete settings._id; + } else { + return this.defaultSettings; + } + + return settings; + } +} + +export default new EmailSettingsService(); diff --git a/src/api/server/services/settings/emailTemplates.js b/src/api/server/services/settings/emailTemplates.js new file mode 100755 index 0000000..e09471f --- /dev/null +++ b/src/api/server/services/settings/emailTemplates.js @@ -0,0 +1,63 @@ +import { db } from '../../lib/mongo'; +import parse from '../../lib/parse'; + +class EmailTemplatesService { + constructor() {} + + getEmailTemplate(name) { + return db + .collection('emailTemplates') + .findOne({ name: name }) + .then(template => { + return this.changeProperties(template); + }); + } + + updateEmailTemplate(name, data) { + const template = this.getValidDocumentForUpdate(data); + return db + .collection('emailTemplates') + .updateOne( + { name: name }, + { + $set: template + }, + { upsert: true } + ) + .then(res => this.getEmailTemplate(name)); + } + + getValidDocumentForUpdate(data) { + if (Object.keys(data).length === 0) { + return new Error('Required fields are missing'); + } + + let template = {}; + + if (data.subject !== undefined) { + template.subject = parse.getString(data.subject); + } + + if (data.body !== undefined) { + template.body = parse.getString(data.body); + } + + return template; + } + + changeProperties(template) { + if (template) { + delete template._id; + delete template.name; + } else { + return { + subject: '', + body: '' + }; + } + + return template; + } +} + +export default new EmailTemplatesService(); diff --git a/src/api/server/services/settings/paymentGateways.js b/src/api/server/services/settings/paymentGateways.js new file mode 100755 index 0000000..4b25fa2 --- /dev/null +++ b/src/api/server/services/settings/paymentGateways.js @@ -0,0 +1,35 @@ +import { db } from '../../lib/mongo'; + +class PaymentGatewaysService { + constructor() {} + + getGateway(gatewayName) { + return db + .collection('paymentGateways') + .findOne({ name: gatewayName }) + .then(data => { + return this.changeProperties(data); + }); + } + + updateGateway(gatewayName, data) { + if (Object.keys(data).length === 0) { + return this.getGateway(gatewayName); + } else { + return db + .collection('paymentGateways') + .updateOne({ name: gatewayName }, { $set: data }, { upsert: true }) + .then(res => this.getGateway(gatewayName)); + } + } + + changeProperties(data) { + if (data) { + delete data._id; + delete data.name; + } + return data; + } +} + +export default new PaymentGatewaysService(); diff --git a/src/api/server/services/settings/settings.js b/src/api/server/services/settings/settings.js new file mode 100755 index 0000000..2b77cf0 --- /dev/null +++ b/src/api/server/services/settings/settings.js @@ -0,0 +1,266 @@ +import path from 'path'; +import fse from 'fs-extra'; +import fs from 'fs'; +import url from 'url'; +import formidable from 'formidable'; +import settings from '../../lib/settings'; +import utils from '../../lib/utils'; +import { db } from '../../lib/mongo'; +import parse from '../../lib/parse'; + +class SettingsService { + constructor() { + this.defaultSettings = { + domain: '', + logo_file: null, + language: 'en', + currency_code: 'USD', + currency_symbol: '$', + currency_format: '${amount}', + thousand_separator: ',', + decimal_separator: '.', + decimal_number: 2, + timezone: 'Asia/Singapore', + date_format: 'MMMM D, YYYY', + time_format: 'h:mm a', + default_shipping_country: 'SG', + default_shipping_state: '', + default_shipping_city: '', + default_product_sorting: 'stock_status,price,position', + product_fields: + 'path,id,name,category_id,category_name,sku,images,enabled,discontinued,stock_status,stock_quantity,price,on_sale,regular_price,attributes,tags,position', + products_limit: 30, + weight_unit: 'kg', + length_unit: 'cm', + hide_billing_address: false, + order_confirmation_copy_to: '' + }; + } + + getSettings() { + return db + .collection('settings') + .findOne() + .then(settings => { + return this.changeProperties(settings); + }); + } + + updateSettings(data) { + const settings = this.getValidDocumentForUpdate(data); + return this.insertDefaultSettingsIfEmpty().then(() => + db + .collection('settings') + .updateOne( + {}, + { + $set: settings + }, + { upsert: true } + ) + .then(res => this.getSettings()) + ); + } + + insertDefaultSettingsIfEmpty() { + return db + .collection('settings') + .countDocuments({}) + .then(count => { + if (count === 0) { + return db.collection('settings').insertOne(this.defaultSettings); + } else { + return; + } + }); + } + + getValidDocumentForUpdate(data) { + if (Object.keys(data).length === 0) { + return new Error('Required fields are missing'); + } + + let settings = {}; + + if (data.language !== undefined) { + settings.language = parse.getString(data.language); + } + + if (data.currency_code !== undefined) { + settings.currency_code = parse.getString(data.currency_code); + } + + if (data.domain !== undefined) { + settings.domain = parse.getString(data.domain); + } + + if (data.currency_symbol !== undefined) { + settings.currency_symbol = parse.getString(data.currency_symbol); + } + + if (data.currency_format !== undefined) { + settings.currency_format = parse.getString(data.currency_format); + } + + if (data.thousand_separator !== undefined) { + settings.thousand_separator = parse.getString(data.thousand_separator); + } + + if (data.decimal_separator !== undefined) { + settings.decimal_separator = parse.getString(data.decimal_separator); + } + + if (data.decimal_number !== undefined) { + settings.decimal_number = + parse.getNumberIfPositive(data.decimal_number) || 0; + } + + if (data.timezone !== undefined) { + settings.timezone = parse.getString(data.timezone); + } + + if (data.date_format !== undefined) { + settings.date_format = parse.getString(data.date_format); + } + + if (data.time_format !== undefined) { + settings.time_format = parse.getString(data.time_format); + } + + if (data.default_shipping_country !== undefined) { + settings.default_shipping_country = parse.getString( + data.default_shipping_country + ); + } + + if (data.default_shipping_state !== undefined) { + settings.default_shipping_state = parse.getString( + data.default_shipping_state + ); + } + + if (data.default_shipping_city !== undefined) { + settings.default_shipping_city = parse.getString( + data.default_shipping_city + ); + } + + if (data.default_product_sorting !== undefined) { + settings.default_product_sorting = parse.getString( + data.default_product_sorting + ); + } + + if (data.product_fields !== undefined) { + settings.product_fields = parse.getString(data.product_fields); + } + + if (data.products_limit !== undefined) { + settings.products_limit = parse.getNumberIfPositive(data.products_limit); + } + + if (data.weight_unit !== undefined) { + settings.weight_unit = parse.getString(data.weight_unit); + } + + if (data.length_unit !== undefined) { + settings.length_unit = parse.getString(data.length_unit); + } + + if (data.logo_file !== undefined) { + settings.logo_file = parse.getString(data.logo_file); + } + + if (data.hide_billing_address !== undefined) { + settings.hide_billing_address = parse.getBooleanIfValid( + data.hide_billing_address, + false + ); + } + + if (data.order_confirmation_copy_to !== undefined) { + settings.order_confirmation_copy_to = parse.getString( + data.order_confirmation_copy_to + ); + } + + return settings; + } + + changeProperties(settingsFromDB) { + const data = Object.assign(this.defaultSettings, settingsFromDB, { + _id: undefined + }); + if (data.domain === null || data.domain === undefined) { + data.domain = ''; + } + + if (data.logo_file && data.logo_file.length > 0) { + data.logo = url.resolve( + data.domain, + settings.filesUploadUrl + '/' + data.logo_file + ); + } else { + data.logo = null; + } + return data; + } + + deleteLogo() { + return this.getSettings().then(data => { + if (data.logo_file && data.logo_file.length > 0) { + let filePath = path.resolve( + settings.filesUploadPath + '/' + data.logo_file + ); + fs.unlink(filePath, err => { + this.updateSettings({ logo_file: null }); + }); + } + }); + } + + uploadLogo(req, res, next) { + let uploadDir = path.resolve(settings.filesUploadPath); + fse.ensureDirSync(uploadDir); + + let form = new formidable.IncomingForm(), + file_name = null, + file_size = 0; + + form.uploadDir = uploadDir; + + form + .on('fileBegin', (name, file) => { + // Emitted whenever a field / value pair has been received. + file.name = utils.getCorrectFileName(file.name); + file.path = uploadDir + '/' + file.name; + }) + .on('file', function(field, file) { + // every time a file has been uploaded successfully, + file_name = file.name; + file_size = file.size; + }) + .on('error', err => { + res.status(500).send(this.getErrorMessage(err)); + }) + .on('end', () => { + //Emitted when the entire request has been received, and all contained files have finished flushing to disk. + if (file_name) { + this.updateSettings({ logo_file: file_name }); + res.send({ file: file_name, size: file_size }); + } else { + res + .status(400) + .send(this.getErrorMessage('Required fields are missing')); + } + }); + + form.parse(req); + } + + getErrorMessage(err) { + return { error: true, message: err.toString() }; + } +} + +export default new SettingsService(); diff --git a/src/api/server/services/sitemap.js b/src/api/server/services/sitemap.js new file mode 100755 index 0000000..4baea86 --- /dev/null +++ b/src/api/server/services/sitemap.js @@ -0,0 +1,181 @@ +import { db } from '../lib/mongo'; +import parse from '../lib/parse'; + +class SitemapService { + constructor() {} + + getPaths(onlyEnabled) { + const slug = null; + onlyEnabled = parse.getBooleanIfValid(onlyEnabled, false); + + return Promise.all([ + this.getSlugArrayFromReserved(), + this.getSlugArrayFromProductCategories(slug, onlyEnabled), + this.getSlugArrayFromProducts(slug, onlyEnabled), + this.getSlugArrayFromPages(slug, onlyEnabled) + ]).then(([reserved, productCategories, products, pages]) => { + let paths = [...reserved, ...productCategories, ...products, ...pages]; + return paths; + }); + } + + getPathsWithoutSlashes(slug, onlyEnabled) { + return Promise.all([ + this.getSlugArrayFromReserved(), + this.getSlugArrayFromProductCategories(slug, onlyEnabled), + this.getSlugArrayFromPages(slug, onlyEnabled) + ]).then(([reserved, productCategories, pages]) => { + let paths = [...reserved, ...productCategories, ...pages]; + return paths; + }); + } + + getPathsWithSlash(slug, onlyEnabled) { + return Promise.all([ + this.getSlugArrayFromProducts(slug, onlyEnabled), + this.getSlugArrayFromPages(slug, onlyEnabled) + ]).then(([products, pages]) => { + let paths = [...products, ...pages]; + return paths; + }); + } + + getSlugArrayFromReserved() { + let paths = []; + + paths.push({ path: '/api', type: 'reserved' }); + paths.push({ path: '/ajax', type: 'reserved' }); + paths.push({ path: '/assets', type: 'reserved' }); + paths.push({ path: '/images', type: 'reserved' }); + paths.push({ path: '/admin', type: 'reserved' }); + paths.push({ path: '/signin', type: 'reserved' }); + paths.push({ path: '/signout', type: 'reserved' }); + paths.push({ path: '/signup', type: 'reserved' }); + paths.push({ path: '/post', type: 'reserved' }); + paths.push({ path: '/posts', type: 'reserved' }); + paths.push({ path: '/public', type: 'reserved' }); + paths.push({ path: '/rss', type: 'reserved' }); + paths.push({ path: '/feed', type: 'reserved' }); + paths.push({ path: '/setup', type: 'reserved' }); + paths.push({ path: '/tag', type: 'reserved' }); + paths.push({ path: '/tags', type: 'reserved' }); + paths.push({ path: '/user', type: 'reserved' }); + paths.push({ path: '/users', type: 'reserved' }); + paths.push({ path: '/sitemap.xml', type: 'reserved' }); + paths.push({ path: '/robots.txt', type: 'reserved' }); + paths.push({ path: '/settings', type: 'reserved' }); + paths.push({ path: '/find', type: 'reserved' }); + paths.push({ path: '/account', type: 'reserved' }); + + paths.push({ path: '/search', type: 'search' }); + + return paths; + } + + getSlugArrayFromProducts(slug, onlyEnabled) { + const categoriesFilter = {}; + const productFilter = {}; + + if (slug) { + const slugParts = slug.split('/'); + categoriesFilter.slug = slugParts[0]; + productFilter.slug = slugParts[1]; + } + + if (onlyEnabled === true) { + productFilter.enabled = true; + } + + return Promise.all([ + db + .collection('productCategories') + .find(categoriesFilter) + .project({ slug: 1 }) + .toArray(), + db + .collection('products') + .find(productFilter) + .project({ slug: 1, category_id: 1 }) + .toArray() + ]).then(([categories, products]) => { + return products.map(product => { + const category = categories.find( + c => c._id.toString() === (product.category_id || '').toString() + ); + const categorySlug = category ? category.slug : '-'; + return { + path: `/${categorySlug}/${product.slug}`, + type: 'product', + resource: product._id + }; + }); + }); + } + + getSlugArrayFromPages(slug, onlyEnabled) { + const filter = this.getFilterWithoutSlashes(slug); + if (onlyEnabled === true) { + filter.enabled = true; + } + + return db + .collection('pages') + .find(filter) + .project({ slug: 1 }) + .toArray() + .then(items => + items.map(item => ({ + path: `/${item.slug}`, + type: 'page', + resource: item._id + })) + ); + } + + getSlugArrayFromProductCategories(slug, onlyEnabled) { + const filter = this.getFilterWithoutSlashes(slug); + if (onlyEnabled === true) { + filter.enabled = true; + } + + return db + .collection('productCategories') + .find(filter) + .project({ slug: 1 }) + .toArray() + .then(items => + items.map(item => ({ + path: `/${item.slug}`, + type: 'product-category', + resource: item._id + })) + ); + } + + getFilterWithoutSlashes(slug) { + if (slug) { + return { slug: slug }; + } else { + return {}; + } + } + + getSinglePath(path, onlyEnabled = false) { + onlyEnabled = parse.getBooleanIfValid(onlyEnabled, false); + // convert path to slash (remove first slash) + const slug = path.substr(1); + if (slug.includes('/')) { + // slug = category-slug/product-slug + return this.getPathsWithSlash(slug, onlyEnabled).then(paths => + paths.find(e => e.path === path) + ); + } else { + // slug = slug + return this.getPathsWithoutSlashes(slug, onlyEnabled).then(paths => + paths.find(e => e.path === path) + ); + } + } +} + +export default new SitemapService(); diff --git a/src/api/server/services/theme/assets.js b/src/api/server/services/theme/assets.js new file mode 100755 index 0000000..7706bc8 --- /dev/null +++ b/src/api/server/services/theme/assets.js @@ -0,0 +1,64 @@ +import path from 'path'; +import fs from 'fs'; +import url from 'url'; +import formidable from 'formidable'; +import settings from '../../lib/settings'; + +class ThemeAssetsService { + deleteFile(fileName) { + return new Promise((resolve, reject) => { + const filePath = path.resolve( + settings.themeAssetsUploadPath + '/' + fileName + ); + if (fs.existsSync(filePath)) { + fs.unlink(filePath, err => { + resolve(); + }); + } else { + reject('File not found'); + } + }); + } + + uploadFile(req, res, next) { + const uploadDir = path.resolve(settings.themeAssetsUploadPath); + + let form = new formidable.IncomingForm(), + file_name = null, + file_size = 0; + + form.uploadDir = uploadDir; + + form + .on('fileBegin', (name, file) => { + // Emitted whenever a field / value pair has been received. + file.path = uploadDir + '/' + file.name; + }) + .on('file', function(field, file) { + // every time a file has been uploaded successfully, + file_name = file.name; + file_size = file.size; + }) + .on('error', err => { + res.status(500).send(this.getErrorMessage(err)); + }) + .on('end', () => { + //Emitted when the entire request has been received, and all contained files have finished flushing to disk. + if (file_name) { + res.send({ file: file_name, size: file_size }); + } else { + res + .status(400) + .send(this.getErrorMessage('Required fields are missing')); + } + }); + + form.parse(req); + } + + getErrorMessage(err) { + return { error: true, message: err.toString() }; + } +} + +export default new ThemeAssetsService(); diff --git a/src/api/server/services/theme/placeholders.js b/src/api/server/services/theme/placeholders.js new file mode 100755 index 0000000..c3303af --- /dev/null +++ b/src/api/server/services/theme/placeholders.js @@ -0,0 +1,99 @@ +import { db } from '../../lib/mongo'; +import parse from '../../lib/parse'; + +class ThemePlaceholdersService { + constructor() {} + + getPlaceholders() { + return db + .collection('themePlaceholders') + .find({}, { _id: 0 }) + .toArray(); + } + + getSinglePlaceholder(placeholderKey) { + return db + .collection('themePlaceholders') + .findOne({ key: placeholderKey }, { _id: 0 }); + } + + addPlaceholder(data) { + const field = this.getValidDocumentForInsert(data); + const placeholderKey = field.key; + + return this.getSinglePlaceholder(placeholderKey).then(placeholder => { + if (placeholder) { + // placeholder exists + return new Error('Placeholder exists'); + } else { + // add + return db + .collection('themePlaceholders') + .insertOne(field) + .then(res => this.getSinglePlaceholder(placeholderKey)); + } + }); + } + + updatePlaceholder(placeholderKey, data) { + const field = this.getValidDocumentForUpdate(data); + return db + .collection('themePlaceholders') + .updateOne( + { key: placeholderKey }, + { + $set: field + }, + { upsert: true } + ) + .then(res => this.getSinglePlaceholder(placeholderKey)); + } + + deletePlaceholder(placeholderKey) { + return db + .collection('themePlaceholders') + .deleteOne({ key: placeholderKey }); + } + + getValidDocumentForUpdate(data) { + if (Object.keys(data).length === 0) { + return new Error('Required fields are missing'); + } + + let field = {}; + + if (data.place !== undefined) { + field.place = parse.getString(data.place); + } + + if (data.value !== undefined) { + field.value = parse.getString(data.value); + } + + return field; + } + + getValidDocumentForInsert(data) { + if (Object.keys(data).length === 0) { + return new Error('Required fields are missing'); + } + + let field = {}; + + if (data.key !== undefined) { + field.key = parse.getString(data.key); + } + + if (data.place !== undefined) { + field.place = parse.getString(data.place); + } + + if (data.value !== undefined) { + field.value = parse.getString(data.value); + } + + return field; + } +} + +export default new ThemePlaceholdersService(); diff --git a/src/api/server/services/theme/settings.js b/src/api/server/services/theme/settings.js new file mode 100755 index 0000000..51fc028 --- /dev/null +++ b/src/api/server/services/theme/settings.js @@ -0,0 +1,79 @@ +import fs from 'fs'; +import path from 'path'; +import lruCache from 'lru-cache'; +import serverSettings from '../../lib/settings'; + +const cache = lruCache({ + max: 10000, + maxAge: 1000 * 60 * 60 * 24 // 24h +}); + +const THEME_SETTINGS_CACHE_KEY = 'themesettings'; +const SETTINGS_FILE = path.resolve('theme/settings/settings.json'); +const SETTINGS_SCHEMA_FILE = path.resolve( + `theme/settings/${serverSettings.language}.json` +); +const SETTINGS_SCHEMA_FILE_EN = path.resolve('theme/settings/en.json'); + +class ThemeSettingsService { + constructor() {} + + readFile(file) { + return new Promise((resolve, reject) => { + fs.readFile(file, 'utf8', (err, data) => { + if (err) { + reject(err); + } else { + let jsonData = {}; + try { + jsonData = data.length > 0 ? JSON.parse(data) : {}; + resolve(jsonData); + } catch (e) { + reject('Failed to parse JSON'); + } + } + }); + }); + } + + writeFile(file, jsonData) { + return new Promise((resolve, reject) => { + const stringData = JSON.stringify(jsonData); + fs.writeFile(file, stringData, err => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + }); + } + + getSettingsSchema() { + if (fs.existsSync(SETTINGS_SCHEMA_FILE)) { + return this.readFile(SETTINGS_SCHEMA_FILE); + } + + // If current locale not exist, use scheme in English + return this.readFile(SETTINGS_SCHEMA_FILE_EN); + } + + getSettings() { + const settingsFromCache = cache.get(THEME_SETTINGS_CACHE_KEY); + + if (settingsFromCache) { + return Promise.resolve(settingsFromCache); + } + return this.readFile(SETTINGS_FILE).then(settings => { + cache.set(THEME_SETTINGS_CACHE_KEY, settings); + return settings; + }); + } + + updateSettings(settings) { + cache.set(THEME_SETTINGS_CACHE_KEY, settings); + return this.writeFile(SETTINGS_FILE, settings); + } +} + +export default new ThemeSettingsService(); diff --git a/src/api/server/services/theme/theme.js b/src/api/server/services/theme/theme.js new file mode 100755 index 0000000..6b26bfc --- /dev/null +++ b/src/api/server/services/theme/theme.js @@ -0,0 +1,102 @@ +import { exec } from 'child_process'; +import path from 'path'; +import formidable from 'formidable'; +import winston from 'winston'; +import settings from '../../lib/settings'; +import dashboardWebSocket from '../../lib/dashboardWebSocket'; + +class ThemesService { + constructor() {} + + exportTheme(req, res) { + const randomFileName = Math.floor(Math.random() * 10000); + exec( + `npm --silent run theme:export -- ${randomFileName}.zip`, + (error, stdout, stderr) => { + if (error) { + winston.error('Exporting theme failed'); + res.status(500).send(this.getErrorMessage(error)); + } else { + winston.info(`Theme successfully exported to ${randomFileName}.zip`); + if (stdout.includes('success')) { + res.send({ file: `/${randomFileName}.zip` }); + } else { + res + .status(500) + .send(this.getErrorMessage('Something went wrong in scripts')); + } + } + } + ); + } + + installTheme(req, res) { + this.saveThemeFile(req, res, (err, fileName) => { + if (err) { + res.status(500).send(this.getErrorMessage(err)); + } else { + // run async NPM script + winston.info('Installing theme...'); + exec(`npm run theme:install ${fileName}`, (error, stdout, stderr) => { + dashboardWebSocket.send({ + event: dashboardWebSocket.events.THEME_INSTALLED, + payload: fileName + }); + + if (error) { + winston.error('Installing theme failed'); + } else { + winston.info('Theme successfully installed'); + } + }); + // close request and don't wait result from NPM script + res.status(200).end(); + } + }); + } + + saveThemeFile(req, res, callback) { + const uploadDir = path.resolve(settings.filesUploadPath); + + let form = new formidable.IncomingForm(), + file_name = null, + file_size = 0; + + form.multiples = false; + + form + .on('fileBegin', (name, file) => { + // Emitted whenever a field / value pair has been received. + if (file.name.endsWith('.zip')) { + file.path = uploadDir + '/' + file.name; + } + // else - will save to /tmp + }) + .on('file', function(field, file) { + // every time a file has been uploaded successfully, + if (file.name.endsWith('.zip')) { + file_name = file.name; + file_size = file.size; + } + }) + .on('error', err => { + callback(err); + }) + .on('end', () => { + //Emitted when the entire request has been received, and all contained files have finished flushing to disk. + if (file_name) { + callback(null, file_name); + } else { + callback('Cant upload file'); + } + }); + + form.parse(req); + } + + getErrorMessage(err) { + return { error: true, message: err.toString() }; + } +} + +export default new ThemesService(); diff --git a/src/api/server/services/webhooks.js b/src/api/server/services/webhooks.js new file mode 100755 index 0000000..a8087f6 --- /dev/null +++ b/src/api/server/services/webhooks.js @@ -0,0 +1,144 @@ +import { ObjectID } from 'mongodb'; +import lruCache from 'lru-cache'; +import { db } from '../lib/mongo'; +import utils from '../lib/utils'; +import parse from '../lib/parse'; + +const cache = lruCache({ + max: 10000, + maxAge: 1000 * 60 * 60 * 24 // 24h +}); + +const WEBHOOKS_CACHE_KEY = 'webhooks'; + +class WebhooksService { + constructor() {} + + async getWebhooks() { + const webhooksFromCache = cache.get(WEBHOOKS_CACHE_KEY); + + if (webhooksFromCache) { + return webhooksFromCache; + } else { + const items = await db + .collection('webhooks') + .find() + .toArray(); + const result = items.map(item => this.changeProperties(item)); + cache.set(WEBHOOKS_CACHE_KEY, result); + return result; + } + } + + async getSingleWebhook(id) { + if (!ObjectID.isValid(id)) { + return Promise.reject('Invalid identifier'); + } + let webhookObjectID = new ObjectID(id); + + const item = await db + .collection('webhooks') + .findOne({ _id: webhookObjectID }); + const result = this.changeProperties(item); + return result; + } + + async addWebhook(data) { + const webhook = this.getValidDocumentForInsert(data); + const res = await db.collection('webhooks').insertMany([webhook]); + cache.del(WEBHOOKS_CACHE_KEY); + const newWebhookId = res.ops[0]._id.toString(); + const newWebhook = await this.getSingleWebhook(newWebhookId); + return newWebhook; + } + + async updateWebhook(id, data) { + if (!ObjectID.isValid(id)) { + return Promise.reject('Invalid identifier'); + } + const webhookObjectID = new ObjectID(id); + const webhook = this.getValidDocumentForUpdate(id, data); + + const res = await db.collection('webhooks').updateOne( + { + _id: webhookObjectID + }, + { + $set: webhook + } + ); + + cache.del(WEBHOOKS_CACHE_KEY); + const newWebhook = await this.getSingleWebhook(id); + return newWebhook; + } + + async deleteWebhook(id) { + if (!ObjectID.isValid(id)) { + return Promise.reject('Invalid identifier'); + } + const webhookObjectID = new ObjectID(id); + const res = await db + .collection('webhooks') + .deleteOne({ _id: webhookObjectID }); + cache.del(WEBHOOKS_CACHE_KEY); + return res.deletedCount > 0; + } + + getValidDocumentForInsert(data) { + let webhook = { + date_created: new Date() + }; + + webhook.description = parse.getString(data.description); + webhook.url = parse.getString(data.url); + webhook.secret = parse.getString(data.secret); + webhook.enabled = parse.getBooleanIfValid(data.enabled, false); + webhook.events = parse.getArrayIfValid(data.events) || []; + + return webhook; + } + + getValidDocumentForUpdate(id, data) { + if (Object.keys(data).length === 0) { + return new Error('Required fields are missing'); + } + + let webhook = { + date_updated: new Date() + }; + + if (data.description !== undefined) { + webhook.description = parse.getString(data.description); + } + + if (data.url !== undefined) { + webhook.url = parse.getString(data.url); + } + + if (data.secret !== undefined) { + webhook.secret = parse.getString(data.secret); + } + + if (data.enabled !== undefined) { + webhook.enabled = parse.getBooleanIfValid(data.enabled, false); + } + + if (data.events !== undefined) { + webhook.events = parse.getArrayIfValid(data.events) || []; + } + + return webhook; + } + + changeProperties(item) { + if (item) { + item.id = item._id.toString(); + item._id = undefined; + } + + return item; + } +} + +export default new WebhooksService(); diff --git a/src/api/server/setup.js b/src/api/server/setup.js new file mode 100755 index 0000000..161be13 --- /dev/null +++ b/src/api/server/setup.js @@ -0,0 +1,420 @@ +import winston from 'winston'; +import url from 'url'; +import { MongoClient } from 'mongodb'; +import logger from './lib/logger'; +import settings from './lib/settings'; + +const mongodbConnection = settings.mongodbServerUrl; +const mongoPathName = url.parse(mongodbConnection).pathname; +const dbName = mongoPathName.substring(mongoPathName.lastIndexOf('/') + 1); + +const CONNECT_OPTIONS = { + useNewUrlParser: true +}; + +const DEFAULT_LANGUAGE = 'english'; + +const addPage = async (db, pageObject) => { + const count = await db + .collection('pages') + .countDocuments({ slug: pageObject.slug }); + const docExists = +count > 0; + if (!docExists) { + await db.collection('pages').insertOne(pageObject); + winston.info(`- Added page: /${pageObject.slug}`); + } +}; + +const addAllPages = async db => { + await addPage(db, { + slug: '', + meta_title: 'Home', + enabled: true, + is_system: true + }); + await addPage(db, { + slug: 'checkout', + meta_title: 'Checkout', + enabled: true, + is_system: true + }); + await addPage(db, { + slug: 'checkout-success', + meta_title: 'Thank You!', + enabled: true, + is_system: true + }); + await addPage(db, { + slug: 'about', + meta_title: 'About us', + enabled: true, + is_system: false + }); +}; + +const addAllProducts = async db => { + const productCategoriesCount = await db + .collection('productCategories') + .countDocuments({}); + + const productsCount = await db.collection('products').countDocuments({}); + + const productsNotExists = productCategoriesCount === 0 && productsCount === 0; + + if (productsNotExists) { + const catA = await db.collection('productCategories').insertOne({ + name: 'Category A', + slug: 'category-a', + image: '', + parent_id: null, + enabled: true + }); + + const catB = await db.collection('productCategories').insertOne({ + name: 'Category B', + slug: 'category-b', + image: '', + parent_id: null, + enabled: true + }); + + const catC = await db.collection('productCategories').insertOne({ + name: 'Category C', + slug: 'category-c', + image: '', + parent_id: null, + enabled: true + }); + + const catA1 = await db.collection('productCategories').insertOne({ + name: 'Subcategory 1', + slug: 'category-a-1', + image: '', + parent_id: catA.insertedId, + enabled: true + }); + + const catA2 = await db.collection('productCategories').insertOne({ + name: 'Subcategory 2', + slug: 'category-a-2', + image: '', + parent_id: catA.insertedId, + enabled: true + }); + + const catA3 = await db.collection('productCategories').insertOne({ + name: 'Subcategory 3', + slug: 'category-a-3', + image: '', + parent_id: catA.insertedId, + enabled: true + }); + + await db.collection('products').insertOne({ + name: 'Product A', + slug: 'product-a', + category_id: catA.insertedId, + regular_price: 950, + stock_quantity: 1, + enabled: true, + discontinued: false, + attributes: [ + { name: 'Brand', value: 'Brand A' }, + { name: 'Size', value: 'M' } + ] + }); + + await db.collection('products').insertOne({ + name: 'Product B', + slug: 'product-b', + category_id: catA.insertedId, + regular_price: 1250, + stock_quantity: 1, + enabled: true, + discontinued: false, + attributes: [ + { name: 'Brand', value: 'Brand B' }, + { name: 'Size', value: 'L' } + ] + }); + + winston.info('- Added products'); + } +}; + +const addEmailTemplates = async db => { + const emailTemplatesCount = await db + .collection('emailTemplates') + .countDocuments({ name: 'order_confirmation' }); + const emailTemplatesNotExists = emailTemplatesCount === 0; + if (emailTemplatesNotExists) { + await db.collection('emailTemplates').insertOne({ + name: 'order_confirmation', + subject: 'Order confirmation', + body: `
+
Order number: {{number}}
+
Shipping method: {{shipping_method}}
+
Payment method: {{payment_method}}
+ +
+ Shipping to

+ Full name: {{shipping_address.full_name}}
+ Address 1: {{shipping_address.address1}}
+ Address 2: {{shipping_address.address2}}
+ Postal code: {{shipping_address.postal_code}}
+ City: {{shipping_address.city}}
+ State: {{shipping_address.state}}
+ Phone: {{shipping_address.phone}} +
+ + + + + + + + + + {{#each items}} + + + + + + + {{/each}} + +
ItemPriceQtyTotal
{{name}}
{{variant_name}}
$ {{price}}{{quantity}}$ {{price_total}}
+ + + + + + + + + + + + + + +
Subtotal$ {{subtotal}}
Shipping$ {{shipping_total}}
Grand total$ {{grand_total}}
+ +
` + }); + + winston.info('- Added email template for Order Confirmation'); + } +}; + +const addShippingMethods = async db => { + const shippingMethodsCount = await db + .collection('shippingMethods') + .countDocuments({}); + const shippingMethodsNotExists = shippingMethodsCount === 0; + if (shippingMethodsNotExists) { + await db.collection('shippingMethods').insertOne({ + name: 'Shipping method A', + enabled: true, + conditions: { + countries: [], + states: [], + cities: [], + subtotal_min: 0, + subtotal_max: 0, + weight_total_min: 0, + weight_total_max: 0 + } + }); + winston.info('- Added shipping method'); + } +}; + +const addPaymentMethods = async db => { + const paymentMethodsCount = await db + .collection('paymentMethods') + .countDocuments({}); + const paymentMethodsNotExists = paymentMethodsCount === 0; + if (paymentMethodsNotExists) { + await db.collection('paymentMethods').insertOne({ + name: 'PayPal', + enabled: true, + conditions: { + countries: [], + shipping_method_ids: [], + subtotal_min: 0, + subtotal_max: 0 + } + }); + winston.info('- Added payment method'); + } +}; + +const createIndex = (db, collectionName, fields, options) => + db.collection(collectionName).createIndex(fields, options); + +const createAllIndexes = async db => { + const pagesIndexes = await db + .collection('pages') + .listIndexes() + .toArray(); + + if (pagesIndexes.length === 1) { + await createIndex(db, 'pages', { enabled: 1 }); + await createIndex(db, 'pages', { slug: 1 }); + winston.info('- Created indexes for: pages'); + } + + const productCategoriesIndexes = await db + .collection('productCategories') + .listIndexes() + .toArray(); + + if (productCategoriesIndexes.length === 1) { + await createIndex(db, 'productCategories', { enabled: 1 }); + await createIndex(db, 'productCategories', { slug: 1 }); + winston.info('- Created indexes for: productCategories'); + } + + const productsIndexes = await db + .collection('products') + .listIndexes() + .toArray(); + + if (productsIndexes.length === 1) { + await createIndex(db, 'products', { slug: 1 }); + await createIndex(db, 'products', { enabled: 1 }); + await createIndex(db, 'products', { category_id: 1 }); + await createIndex(db, 'products', { sku: 1 }); + await createIndex(db, 'products', { + 'attributes.name': 1, + 'attributes.value': 1 + }); + await createIndex( + db, + 'products', + { + name: 'text', + description: 'text' + }, + { default_language: DEFAULT_LANGUAGE, name: 'textIndex' } + ); + winston.info('- Created indexes for: products'); + } + + const customersIndexes = await db + .collection('customers') + .listIndexes() + .toArray(); + + if (customersIndexes.length === 1) { + await createIndex(db, 'customers', { group_id: 1 }); + await createIndex(db, 'customers', { email: 1 }); + await createIndex(db, 'customers', { mobile: 1 }); + await createIndex( + db, + 'customers', + { + full_name: 'text', + 'addresses.address1': 'text' + }, + { default_language: DEFAULT_LANGUAGE, name: 'textIndex' } + ); + winston.info('- Created indexes for: customers'); + } + + const ordersIndexes = await db + .collection('orders') + .listIndexes() + .toArray(); + + if (ordersIndexes.length === 1) { + await createIndex(db, 'orders', { draft: 1 }); + await createIndex(db, 'orders', { number: 1 }); + await createIndex(db, 'orders', { customer_id: 1 }); + await createIndex(db, 'orders', { email: 1 }); + await createIndex(db, 'orders', { mobile: 1 }); + await createIndex( + db, + 'orders', + { + 'shipping_address.full_name': 'text', + 'shipping_address.address1': 'text' + }, + { default_language: DEFAULT_LANGUAGE, name: 'textIndex' } + ); + winston.info('- Created indexes for: orders'); + } +}; + +const addUser = async (db, userEmail) => { + if (userEmail && userEmail.includes('@')) { + const tokensCount = await db.collection('tokens').countDocuments({ + email: userEmail + }); + const tokensNotExists = tokensCount === 0; + + if (tokensNotExists) { + await db.collection('tokens').insertOne({ + is_revoked: false, + date_created: new Date(), + expiration: 72, + name: 'Owner', + email: userEmail, + scopes: ['admin'] + }); + winston.info(`- Added token with email: ${userEmail}`); + } + } +}; + +const addSettings = async (db, { domain }) => { + if (domain && (domain.includes('https://') || domain.includes('http://'))) { + await db.collection('settings').updateOne( + {}, + { + $set: { + domain + } + }, + { upsert: true } + ); + winston.info(`- Set domain: ${domain}`); + } +}; + +(async () => { + let client = null; + let db = null; + + try { + client = await MongoClient.connect( + mongodbConnection, + CONNECT_OPTIONS + ); + db = client.db(dbName); + winston.info(`Successfully connected to ${mongodbConnection}`); + } catch (e) { + winston.error(`MongoDB connection was failed. ${e.message}`); + return; + } + + const userEmail = process.argv.length > 2 ? process.argv[2] : null; + const domain = process.argv.length > 3 ? process.argv[3] : null; + + await db.createCollection('customers'); + await db.createCollection('orders'); + await addAllPages(db); + await addAllProducts(db); + await addEmailTemplates(db); + await addShippingMethods(db); + await addPaymentMethods(db); + await createAllIndexes(db); + await addUser(db, userEmail); + await addSettings(db, { + domain + }); + + client.close(); +})();