Compare commits
No commits in common. "main" and "config-updates" have entirely different histories.
main
...
config-upd
11
.env.example
11
.env.example
@ -1,2 +1,9 @@
|
|||||||
SESSION_SECRET='SECRET'
|
DB_LINK="mongo-link-to-connect"
|
||||||
KEYCLOAK_CLIENT_SECRET='SECRET'
|
|
||||||
|
JWT_SECRET="token"
|
||||||
|
|
||||||
|
APP_URL_CLIENT=https://material-dashboard-react-node.creative-tim.com
|
||||||
|
APP_URL_API=https://node-json-api-free.creative-tim.com/login
|
||||||
|
|
||||||
|
MAILTRAP_USER=
|
||||||
|
MAILTRAP_PASSWORD=
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
@ -140,8 +140,3 @@ gocdefile/*
|
|||||||
gcodefile
|
gcodefile
|
||||||
gcodefiles/*
|
gcodefiles/*
|
||||||
gcodefiles
|
gcodefiles
|
||||||
|
|
||||||
test-results.xml
|
|
||||||
|
|
||||||
# Jenkins generated build metadata
|
|
||||||
src/buildInfo.json
|
|
||||||
|
|||||||
132
Jenkinsfile
vendored
132
Jenkinsfile
vendored
@ -1,132 +0,0 @@
|
|||||||
properties([
|
|
||||||
buildDiscarder(logRotator(numToKeepStr: '25'))
|
|
||||||
])
|
|
||||||
|
|
||||||
pipeline {
|
|
||||||
agent {
|
|
||||||
label 'ubuntu'
|
|
||||||
}
|
|
||||||
|
|
||||||
environment {
|
|
||||||
NODE_ENV = 'production'
|
|
||||||
}
|
|
||||||
|
|
||||||
stages {
|
|
||||||
stage('Checkout') {
|
|
||||||
steps {
|
|
||||||
checkout scm
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
stage('Setup Node.js') {
|
|
||||||
steps {
|
|
||||||
nodejs(nodeJSInstallationName: 'Node23') {
|
|
||||||
sh 'node -v'
|
|
||||||
sh 'pnpm -v'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
stage('Write Build Metadata') {
|
|
||||||
steps {
|
|
||||||
nodejs(nodeJSInstallationName: 'Node23') {
|
|
||||||
sh '''
|
|
||||||
node -e "const fs = require('fs'); fs.writeFileSync('src/buildInfo.json', JSON.stringify({ buildNumber: process.env.BUILD_NUMBER || 'dev' }, null, 2) + '\\n');"
|
|
||||||
'''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
stage('Install Dependencies') {
|
|
||||||
steps {
|
|
||||||
nodejs(nodeJSInstallationName: 'Node23') {
|
|
||||||
sh 'pnpm install --frozen-lockfile --production=false'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
stage('Run Tests') {
|
|
||||||
steps {
|
|
||||||
nodejs(nodeJSInstallationName: 'Node23') {
|
|
||||||
sh '''
|
|
||||||
export NODE_ENV=test
|
|
||||||
export SESSION_SECRET=test-session-secret-for-testing-only
|
|
||||||
pnpm test
|
|
||||||
'''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
post {
|
|
||||||
always {
|
|
||||||
junit 'test-results.xml'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
stage('Deploy via SSH') {
|
|
||||||
steps {
|
|
||||||
sshPublisher(publishers: [
|
|
||||||
sshPublisherDesc(
|
|
||||||
configName: 'farmcontrol.tombutcher.local',
|
|
||||||
transfers: [
|
|
||||||
sshTransfer(
|
|
||||||
cleanRemote: false,
|
|
||||||
excludes: '*.*',
|
|
||||||
execCommand: '''
|
|
||||||
if [ -e /home/farmcontrol/farmcontrol-api/src ]; then
|
|
||||||
rm -rf /home/farmcontrol/farmcontrol-api/src
|
|
||||||
fi
|
|
||||||
if [ -e /home/farmcontrol/farmcontrol-api/package.json ]; then
|
|
||||||
rm -rf /home/farmcontrol/farmcontrol-api/package.json
|
|
||||||
fi
|
|
||||||
if [ -e /home/farmcontrol/farmcontrol-api/package-lock.json ]; then
|
|
||||||
rm -rf /home/farmcontrol/farmcontrol-api/package-lock.json
|
|
||||||
fi
|
|
||||||
if [ -e /home/farmcontrol/farmcontrol-api/node_modules ]; then
|
|
||||||
rm -rf /home/farmcontrol/farmcontrol-api/node_modules
|
|
||||||
fi
|
|
||||||
''',
|
|
||||||
execTimeout: 120000,
|
|
||||||
flatten: true,
|
|
||||||
makeEmptyDirs: true,
|
|
||||||
noDefaultExcludes: false,
|
|
||||||
patternSeparator: '[, ]+',
|
|
||||||
remoteDirectory: 'farmcontrol-api',
|
|
||||||
remoteDirectorySDF: false,
|
|
||||||
removePrefix: '',
|
|
||||||
sourceFiles: ''
|
|
||||||
),
|
|
||||||
sshTransfer(
|
|
||||||
cleanRemote: false,
|
|
||||||
excludes: 'node_modules/**',
|
|
||||||
execCommand: '''
|
|
||||||
cd /home/farmcontrol/farmcontrol-api
|
|
||||||
pnpm install --production
|
|
||||||
sudo systemctl restart farmcontrol-api
|
|
||||||
''',
|
|
||||||
execTimeout: 120000,
|
|
||||||
flatten: false,
|
|
||||||
makeEmptyDirs: true,
|
|
||||||
noDefaultExcludes: false,
|
|
||||||
patternSeparator: '[, ]+',
|
|
||||||
remoteDirectory: 'farmcontrol-api',
|
|
||||||
remoteDirectorySDF: false,
|
|
||||||
removePrefix: '',
|
|
||||||
sourceFiles: '**/*'
|
|
||||||
)
|
|
||||||
],
|
|
||||||
usePromotionTimestamp: false,
|
|
||||||
useWorkspaceInPromotion: false,
|
|
||||||
verbose: true
|
|
||||||
)
|
|
||||||
])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
post {
|
|
||||||
always {
|
|
||||||
cleanWs()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,7 +1,5 @@
|
|||||||
# FarmControl API
|
# FarmControl API
|
||||||
|
|
||||||
[](https://ci.tombutcher.work/job/farmcontrol/job/farmcontrol-api/job/main/)
|
|
||||||
|
|
||||||
A comprehensive REST API for managing 3D printing farms, inventory, and production workflows. Built with Node.js, Express, and MongoDB, featuring authentication with Keycloak and comprehensive logging.
|
A comprehensive REST API for managing 3D printing farms, inventory, and production workflows. Built with Node.js, Express, and MongoDB, featuring authentication with Keycloak and comprehensive logging.
|
||||||
|
|
||||||
## 🚀 Features
|
## 🚀 Features
|
||||||
@ -155,7 +153,7 @@ The application uses Keycloak for authentication. Configure your Keycloak server
|
|||||||
The application uses MongoDB. Ensure your MongoDB instance is running and accessible at the configured `DB_LINK`.
|
The application uses MongoDB. Ensure your MongoDB instance is running and accessible at the configured `DB_LINK`.
|
||||||
|
|
||||||
### CORS Configuration
|
### CORS Configuration
|
||||||
Allowed request origins are configured via `server.corsOrigins` in `config.json`. When omitted, the API falls back to `app.urlClient` and `app.urlElectronClient`.
|
The application is configured to allow requests only from the specified `APP_URL_CLIENT`. Update this in your environment configuration.
|
||||||
|
|
||||||
## 🚀 Deployment
|
## 🚀 Deployment
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
presets: [['@babel/preset-env', { targets: { node: 'current' } }]],
|
|
||||||
plugins: ['transform-import-meta'],
|
|
||||||
};
|
|
||||||
119
config.json
119
config.json
@ -2,31 +2,22 @@
|
|||||||
"development": {
|
"development": {
|
||||||
"server": {
|
"server": {
|
||||||
"port": 8787,
|
"port": 8787,
|
||||||
"logLevel": "trace",
|
"logLevel": "debug"
|
||||||
"corsOrigins": [
|
|
||||||
"https://web.farmcontrol.app",
|
|
||||||
"https://dev.tombutcher.work",
|
|
||||||
"http://localhost:5173",
|
|
||||||
"http://localhost:3000",
|
|
||||||
"http://localhost:5780"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"keycloak": {
|
"keycloak": {
|
||||||
"url": "https://auth.tombutcher.work",
|
"url": "https://auth.tombutcher.work",
|
||||||
"realm": "master",
|
"realm": "master",
|
||||||
"clientId": "farmcontrol-dev"
|
"clientId": "farmcontrol-client"
|
||||||
},
|
},
|
||||||
"requiredRoles": []
|
"requiredRoles": []
|
||||||
},
|
},
|
||||||
"app": {
|
"app": {
|
||||||
"urlClient": "https://dev.tombutcher.work",
|
"urlClient": "http://localhost:3000",
|
||||||
"urlElectronClient": "http://localhost:5780",
|
"urlElectronClient": "http://localhost:3000",
|
||||||
"urlApi": "https://dev.tombutcher.work/api",
|
"urlApi": "http://localhost:8787",
|
||||||
"devAuthClient": "http://localhost:3500",
|
"devAuthClient": "http://localhost:3500"
|
||||||
"jenkinsProject": "https://ci.tombutcher.work/job/farmcontrol/job/farmcontrol-ui",
|
|
||||||
"appUpdateStorage": "https://dist.farmcontrol.app/jenkins/farmcontrol/farmcontrol-ui"
|
|
||||||
},
|
},
|
||||||
"database": {
|
"database": {
|
||||||
"mongo": {
|
"mongo": {
|
||||||
@ -55,84 +46,12 @@
|
|||||||
"filesBucket": "farmcontrol"
|
"filesBucket": "farmcontrol"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"smtp": {
|
|
||||||
"host": "mail.tombutcher.work",
|
|
||||||
"port": 465,
|
|
||||||
"secure": true,
|
|
||||||
"auth": {
|
|
||||||
"user": "farmcontrol",
|
|
||||||
"pass": "XwV5u3jWufuo5E5U4N9hBHfNfwk28D7fNdFN"
|
|
||||||
},
|
|
||||||
"from": "FarmControl <farmcontrol@tombutcher.work>"
|
|
||||||
},
|
|
||||||
"otpExpiryMins": 0.5
|
|
||||||
},
|
|
||||||
"test": {
|
|
||||||
"server": {
|
|
||||||
"port": 8788,
|
|
||||||
"logLevel": "error"
|
|
||||||
},
|
|
||||||
"auth": {
|
|
||||||
"enabled": false,
|
|
||||||
"sessionSecret": "test-session-secret-for-testing-only",
|
|
||||||
"keycloak": {
|
|
||||||
"url": "http://localhost:8080",
|
|
||||||
"realm": "test",
|
|
||||||
"clientId": "test-client"
|
|
||||||
},
|
|
||||||
"requiredRoles": []
|
|
||||||
},
|
|
||||||
"app": {
|
|
||||||
"urlClient": "http://localhost:3000",
|
|
||||||
"urlElectronClient": "http://localhost:5780",
|
|
||||||
"urlApi": "http://localhost:8788/api",
|
|
||||||
"devAuthClient": "http://localhost:3500",
|
|
||||||
"jenkinsProject": "https://ci.tombutcher.work/job/farmcontrol/job/farmcontrol-ui",
|
|
||||||
"appUpdateStorage": "https://dist.farmcontrol.app/jenkins/farmcontrol/farmcontrol-ui"
|
|
||||||
},
|
|
||||||
"database": {
|
|
||||||
"mongo": {
|
|
||||||
"url": "mongodb://127.0.0.1:27017/farmcontrol-test",
|
|
||||||
"link": "127.0.0.1:27017"
|
|
||||||
},
|
|
||||||
"redis": {
|
|
||||||
"host": "localhost",
|
|
||||||
"port": 6379,
|
|
||||||
"password": "",
|
|
||||||
"cacheTtl": 30
|
|
||||||
},
|
|
||||||
"nats": {
|
|
||||||
"host": "localhost",
|
|
||||||
"port": 4222
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"storage": {
|
|
||||||
"fileStorage": "./test-uploads",
|
|
||||||
"ceph": {
|
|
||||||
"accessKeyId": "minioadmin",
|
|
||||||
"secretAccessKey": "minioadmin123",
|
|
||||||
"endpoint": "http://127.0.0.1:9000",
|
|
||||||
"region": "us-east-1",
|
|
||||||
"filesBucket": "farmcontrol-test"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"smtp": {
|
|
||||||
"host": "localhost",
|
|
||||||
"port": 587,
|
|
||||||
"secure": false,
|
|
||||||
"auth": {
|
|
||||||
"user": "",
|
|
||||||
"pass": ""
|
|
||||||
},
|
|
||||||
"from": "FarmControl <farmcontrol@tombutcher.work>"
|
|
||||||
},
|
|
||||||
"otpExpiryMins": 0.5
|
"otpExpiryMins": 0.5
|
||||||
},
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"server": {
|
"server": {
|
||||||
"port": 8080,
|
"port": 8080,
|
||||||
"logLevel": "info",
|
"logLevel": "info"
|
||||||
"corsOrigins": ["https://web.farmcontrol.app", "views://mainview"]
|
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
@ -144,17 +63,15 @@
|
|||||||
"requiredRoles": []
|
"requiredRoles": []
|
||||||
},
|
},
|
||||||
"app": {
|
"app": {
|
||||||
"urlClient": "https://web.farmcontrol.app",
|
"urlClient": "http://localhost:3000",
|
||||||
"urlElectronClient": "http://localhost:3000",
|
"urlElectronClient": "http://localhost:3000",
|
||||||
"urlApi": "https://api.farmcontrol.app",
|
"urlApi": "http://localhost:8080",
|
||||||
"devAuthClient": "http://localhost:3500",
|
"devAuthClient": "http://localhost:3500"
|
||||||
"jenkinsProject": "https://ci.tombutcher.work/job/farmcontrol/job/farmcontrol-ui",
|
|
||||||
"appUpdateStorage": "https://dist.farmcontrol.app/jenkins/farmcontrol/farmcontrol-ui"
|
|
||||||
},
|
},
|
||||||
"database": {
|
"database": {
|
||||||
"mongo": {
|
"mongo": {
|
||||||
"url": "mongodb://192.168.68.38:27017/farmcontrol",
|
"url": "mongodb://localhost:27017/farmcontrol",
|
||||||
"link": "192.168.68.38:27017"
|
"link": "localhost:27017"
|
||||||
},
|
},
|
||||||
"redis": {
|
"redis": {
|
||||||
"url": "",
|
"url": "",
|
||||||
@ -173,20 +90,10 @@
|
|||||||
"ceph": {
|
"ceph": {
|
||||||
"accessKeyId": "minioadmin",
|
"accessKeyId": "minioadmin",
|
||||||
"secretAccessKey": "minioadmin123",
|
"secretAccessKey": "minioadmin123",
|
||||||
"endpoint": "http://192.168.68.39:9000",
|
"endpoint": "http://127.0.0.1:9000",
|
||||||
"region": "us-east-1",
|
"region": "us-east-1",
|
||||||
"filesBucket": "farmcontrol"
|
"filesBucket": "farmcontrol"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"smtp": {
|
|
||||||
"host": "mail.tombutcher.work",
|
|
||||||
"port": 465,
|
|
||||||
"secure": true,
|
|
||||||
"auth": {
|
|
||||||
"user": "farmcontrol",
|
|
||||||
"pass": "XwV5u3jWufuo5E5U4N9hBHfNfwk28D7fNdFN"
|
|
||||||
},
|
|
||||||
"from": "FarmControl <noreply@farmcontrol.app>"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,14 +0,0 @@
|
|||||||
export default {
|
|
||||||
apps: [
|
|
||||||
{
|
|
||||||
name: 'farmcontrol-api',
|
|
||||||
script: 'src/index.js',
|
|
||||||
env: {
|
|
||||||
NODE_ENV: 'development',
|
|
||||||
},
|
|
||||||
env_production: {
|
|
||||||
NODE_ENV: 'production',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
BIN
images/admin.jpg
Normal file
BIN
images/admin.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 85 KiB |
BIN
images/creator.jpg
Normal file
BIN
images/creator.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 187 KiB |
BIN
images/member.jpg
Normal file
BIN
images/member.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 79 KiB |
@ -1,22 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
testEnvironment: 'node',
|
|
||||||
transform: {},
|
|
||||||
moduleFileExtensions: ['js', 'json', 'jsx', 'node'],
|
|
||||||
testMatch: ['**/__tests__/**/*.js', '**/?(*.)+(spec|test).js'],
|
|
||||||
verbose: true,
|
|
||||||
reporters: [
|
|
||||||
'default',
|
|
||||||
[
|
|
||||||
'jest-junit',
|
|
||||||
{
|
|
||||||
outputDirectory: '.',
|
|
||||||
outputName: 'test-results.xml',
|
|
||||||
suiteName: 'farmcontrol-api-tests',
|
|
||||||
classNameTemplate: '{classname}',
|
|
||||||
titleTemplate: '{title}',
|
|
||||||
ancestorSeparator: ' › ',
|
|
||||||
usePathForSuiteName: 'true',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
],
|
|
||||||
};
|
|
||||||
12211
package-lock.json
generated
Normal file
12211
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
package.json
31
package.json
@ -9,24 +9,19 @@
|
|||||||
"@nats-io/transport-node": "^3.2.0",
|
"@nats-io/transport-node": "^3.2.0",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"blurhash": "^2.0.5",
|
|
||||||
"body-parser": "^2.2.0",
|
"body-parser": "^2.2.0",
|
||||||
"canonical-json": "^0.2.0",
|
"canonical-json": "^0.2.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"diff": "^9.0.0",
|
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"exceljs": "^4.4.0",
|
|
||||||
"exifr": "^7.1.3",
|
"exifr": "^7.1.3",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"express-session": "^1.18.2",
|
"express-session": "^1.18.2",
|
||||||
"i": "^0.3.7",
|
"i": "^0.3.7",
|
||||||
"ioredis": "^6.0.0",
|
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"keycloak-connect": "^26.1.1",
|
"keycloak-connect": "^26.1.1",
|
||||||
"lodash": "^4.17.23",
|
|
||||||
"log4js": "^6.9.1",
|
"log4js": "^6.9.1",
|
||||||
"mongodb": "^6.21.0",
|
"mongodb": "^6.21.0",
|
||||||
"mongoose": "^9.9.1",
|
"mongoose": "^8.19.4",
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
"nanoid": "^5.1.6",
|
"nanoid": "^5.1.6",
|
||||||
"node-cache": "^5.1.2",
|
"node-cache": "^5.1.2",
|
||||||
@ -34,10 +29,8 @@
|
|||||||
"nodemailer": "*",
|
"nodemailer": "*",
|
||||||
"nodemon": "^3.1.11",
|
"nodemon": "^3.1.11",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"puppeteer": "^24.37.5",
|
"redis": "^5.10.0",
|
||||||
"sequelize": "^6.37.7",
|
"sequelize": "^6.37.7"
|
||||||
"sharp": "^0.34.3",
|
|
||||||
"speedgoose": "^2.5.11"
|
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@ -48,31 +41,23 @@
|
|||||||
"@babel/plugin-proposal-object-rest-spread": "^7.20.7",
|
"@babel/plugin-proposal-object-rest-spread": "^7.20.7",
|
||||||
"@babel/preset-env": "^7.28.5",
|
"@babel/preset-env": "^7.28.5",
|
||||||
"@babel/register": "^7.28.3",
|
"@babel/register": "^7.28.3",
|
||||||
"@jest/globals": "^30.2.0",
|
|
||||||
"babel-jest": "^30.2.0",
|
|
||||||
"babel-plugin-transform-import-meta": "^2.3.3",
|
|
||||||
"concurrently": "^9.2.1",
|
"concurrently": "^9.2.1",
|
||||||
"eslint": "^9.39.1",
|
"eslint": "^9.39.1",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-prettier": "^5.5.4",
|
"eslint-plugin-prettier": "^5.5.4",
|
||||||
"jest": "^30.2.0",
|
|
||||||
"jest-junit": "^16.0.0",
|
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
"sequelize-cli": "^6.6.3",
|
"sequelize-cli": "^6.6.3",
|
||||||
"standard": "^17.1.2",
|
"standard": "^17.1.2"
|
||||||
"supertest": "^7.1.4"
|
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"syncModelsWithWS": "node fcdev.js",
|
"syncModelsWithWS": "node fcdev.js",
|
||||||
"watch:schemas": "nodemon --config nodemon.schemas.json",
|
"watch:schemas": "nodemon --config nodemon.schemas.json",
|
||||||
"dev": "concurrently --kill-others --names \"API,SCHEMAS\" --prefix-colors \"cyan,yellow\" \"nodemon --exec babel-node --experimental-specifier-resolution=node src/index.js\" \"nodemon --config nodemon.schemas.json\"",
|
"dev": "concurrently --names \"API,SCHEMAS\" --prefix-colors \"cyan,yellow\" \"nodemon --exec babel-node --experimental-specifier-resolution=node src/index.js\" \"nodemon --config nodemon.schemas.json\"",
|
||||||
"dev:api": "nodemon --exec babel-node --experimental-specifier-resolution=node src/index.js",
|
"dev:api": "nodemon --exec babel-node --experimental-specifier-resolution=node src/index.js",
|
||||||
"test": "node --experimental-vm-modules ./node_modules/jest/bin/jest.js",
|
"test": "echo \"Error: no test specified\" && exit 1",
|
||||||
"seed": "node src/mongo/seedData.js",
|
"seed": "node src/mongo/seedData.js",
|
||||||
"clear": "node src/mongo/clearDbs.js",
|
"clear": "node src/mongo/clearDbs.js"
|
||||||
"start": "node src/index.js"
|
|
||||||
},
|
},
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC"
|
||||||
"packageManager": "pnpm@10.28.0"
|
|
||||||
}
|
}
|
||||||
|
|||||||
11384
pnpm-lock.yaml
generated
11384
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -1,9 +0,0 @@
|
|||||||
minimumReleaseAge: 0
|
|
||||||
|
|
||||||
allowBuilds:
|
|
||||||
bcrypt: true
|
|
||||||
chromedriver: true
|
|
||||||
core-js: true
|
|
||||||
puppeteer: true
|
|
||||||
sharp: true
|
|
||||||
unrs-resolver: true
|
|
||||||
@ -1,138 +0,0 @@
|
|||||||
import { jest } from '@jest/globals';
|
|
||||||
|
|
||||||
const userQueryMock = {
|
|
||||||
lean: jest.fn(),
|
|
||||||
};
|
|
||||||
const userModelMock = {
|
|
||||||
findOne: jest.fn(() => userQueryMock),
|
|
||||||
};
|
|
||||||
const appPasswordQueryMock = {
|
|
||||||
select: jest.fn(),
|
|
||||||
lean: jest.fn(),
|
|
||||||
};
|
|
||||||
const appPasswordModelMock = {
|
|
||||||
find: jest.fn(() => appPasswordQueryMock),
|
|
||||||
};
|
|
||||||
const bcryptCompareMock = jest.fn();
|
|
||||||
|
|
||||||
jest.unstable_mockModule('../config.js', () => ({
|
|
||||||
default: { server: { logLevel: 'info' } },
|
|
||||||
getEnvironment: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.unstable_mockModule('log4js', () => ({
|
|
||||||
default: {
|
|
||||||
getLogger: () => ({
|
|
||||||
level: 'info',
|
|
||||||
debug: jest.fn(),
|
|
||||||
error: jest.fn(),
|
|
||||||
warn: jest.fn(),
|
|
||||||
info: jest.fn(),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.unstable_mockModule('node-cache', () => ({
|
|
||||||
default: class {
|
|
||||||
get() {}
|
|
||||||
set() {}
|
|
||||||
on() {}
|
|
||||||
flushAll() {}
|
|
||||||
getStats() {}
|
|
||||||
del() {}
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.unstable_mockModule('bcrypt', () => ({
|
|
||||||
default: { compare: bcryptCompareMock },
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.unstable_mockModule('../database/schemas/management/user.schema.js', () => ({
|
|
||||||
userModel: userModelMock,
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.unstable_mockModule('../database/schemas/management/apppassword.schema.js', () => ({
|
|
||||||
appPasswordModel: appPasswordModelMock,
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.unstable_mockModule('../database/database.js', () => ({
|
|
||||||
getPropertyValues: jest.fn(),
|
|
||||||
getObject: jest.fn(),
|
|
||||||
getObjectNeighbors: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.unstable_mockModule('../database/schemas/management/host.schema.js', () => ({
|
|
||||||
hostModel: {},
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.unstable_mockModule('../services/misc/auth.js', () => ({
|
|
||||||
getSession: jest.fn(),
|
|
||||||
lookupUserByToken: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { isAppAuthenticated } = await import('../keycloak.js');
|
|
||||||
|
|
||||||
describe('app-password authentication', () => {
|
|
||||||
let req;
|
|
||||||
let res;
|
|
||||||
let next;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
appPasswordQueryMock.select.mockReturnValue(appPasswordQueryMock);
|
|
||||||
req = {
|
|
||||||
headers: {
|
|
||||||
authorization: `Basic ${Buffer.from('slicer-user:app-secret').toString('base64')}`,
|
|
||||||
},
|
|
||||||
params: { username: 'slicer-user' },
|
|
||||||
};
|
|
||||||
res = {
|
|
||||||
status: jest.fn().mockReturnThis(),
|
|
||||||
json: jest.fn(),
|
|
||||||
};
|
|
||||||
next = jest.fn();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts a valid username and app password', async () => {
|
|
||||||
const user = { _id: 'user-1', username: 'slicer-user' };
|
|
||||||
userQueryMock.lean.mockResolvedValue(user);
|
|
||||||
appPasswordQueryMock.lean.mockResolvedValue([{ secret: 'stored-hash' }]);
|
|
||||||
bcryptCompareMock.mockResolvedValue(true);
|
|
||||||
|
|
||||||
await isAppAuthenticated(req, res, next);
|
|
||||||
|
|
||||||
expect(req.user).toBe(user);
|
|
||||||
expect(next).toHaveBeenCalledTimes(1);
|
|
||||||
expect(res.status).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts the app password from the password route parameter', async () => {
|
|
||||||
const user = { _id: 'user-1', username: 'slicer-user' };
|
|
||||||
req.headers = {};
|
|
||||||
req.params.password = 'app-secret';
|
|
||||||
userQueryMock.lean.mockResolvedValue(user);
|
|
||||||
appPasswordQueryMock.lean.mockResolvedValue([{ secret: 'stored-hash' }]);
|
|
||||||
bcryptCompareMock.mockResolvedValue(true);
|
|
||||||
|
|
||||||
await isAppAuthenticated(req, res, next);
|
|
||||||
|
|
||||||
expect(bcryptCompareMock).toHaveBeenCalledWith('app-secret', 'stored-hash');
|
|
||||||
expect(req.user).toBe(user);
|
|
||||||
expect(next).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects an invalid app password', async () => {
|
|
||||||
userQueryMock.lean.mockResolvedValue({ _id: 'user-1', username: 'slicer-user' });
|
|
||||||
appPasswordQueryMock.lean.mockResolvedValue([{ secret: 'stored-hash' }]);
|
|
||||||
bcryptCompareMock.mockResolvedValue(false);
|
|
||||||
|
|
||||||
await isAppAuthenticated(req, res, next);
|
|
||||||
|
|
||||||
expect(next).not.toHaveBeenCalled();
|
|
||||||
expect(res.status).toHaveBeenCalledWith(401);
|
|
||||||
expect(res.json).toHaveBeenCalledWith({
|
|
||||||
error: 'Not Authenticated',
|
|
||||||
code: 'UNAUTHORIZED',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,74 +0,0 @@
|
|||||||
import { jest } from '@jest/globals';
|
|
||||||
import request from 'supertest';
|
|
||||||
|
|
||||||
// Mock Keycloak and Auth
|
|
||||||
jest.unstable_mockModule('../keycloak.js', () => ({
|
|
||||||
keycloak: {
|
|
||||||
middleware: () => (req, res, next) => next(),
|
|
||||||
protect: () => (req, res, next) => next(),
|
|
||||||
},
|
|
||||||
isAuthenticated: (req, res, next) => next(),
|
|
||||||
isAppAuthenticated: (req, res, next) => next(),
|
|
||||||
isAppQueryAuthenticated: (req, res, next) => next(),
|
|
||||||
expressSession: (req, res, next) => next(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock database connections and initializations in index.js
|
|
||||||
jest.unstable_mockModule('../database/mongo.js', () => ({
|
|
||||||
dbConnect: jest.fn(),
|
|
||||||
}));
|
|
||||||
jest.unstable_mockModule('../database/nats.js', () => ({
|
|
||||||
natsServer: { connect: jest.fn() },
|
|
||||||
}));
|
|
||||||
jest.unstable_mockModule('../database/ceph.js', () => ({
|
|
||||||
initializeBuckets: jest.fn(),
|
|
||||||
uploadFile: jest.fn(),
|
|
||||||
downloadFile: jest.fn(),
|
|
||||||
deleteFile: jest.fn(),
|
|
||||||
fileExists: jest.fn(),
|
|
||||||
listFiles: jest.fn(),
|
|
||||||
getFileMetadata: jest.fn(),
|
|
||||||
getPresignedUrl: jest.fn(),
|
|
||||||
BUCKETS: { FILES: 'test-bucket' },
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock the service handlers to avoid database calls
|
|
||||||
jest.unstable_mockModule('../services/management/users.js', () => ({
|
|
||||||
listUsersRouteHandler: jest.fn((req, res) => res.send([{ id: '1', name: 'Mock User' }])),
|
|
||||||
listUsersByPropertiesRouteHandler: jest.fn(),
|
|
||||||
searchUsersRouteHandler: jest.fn(),
|
|
||||||
getUserRouteHandler: jest.fn((req, res) => res.send({ id: req.params.id, name: 'Mock User' })),
|
|
||||||
editUserRouteHandler: jest.fn(),
|
|
||||||
getUserStatsRouteHandler: jest.fn(),
|
|
||||||
getUserHistoryRouteHandler: jest.fn(),
|
|
||||||
setAppPasswordRouteHandler: jest.fn(),
|
|
||||||
getUserPropertyValuesRouteHandler: jest.fn(),
|
|
||||||
getUserNeighborsRouteHandler: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { default: app } = await import('../index.js');
|
|
||||||
const { listUsersRouteHandler, getUserRouteHandler } = await import(
|
|
||||||
'../services/management/users.js'
|
|
||||||
);
|
|
||||||
|
|
||||||
describe('Users API Endpoints', () => {
|
|
||||||
describe('GET /users', () => {
|
|
||||||
it('should return a list of users', async () => {
|
|
||||||
const response = await request(app).get('/users');
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(response.body).toEqual([{ id: '1', name: 'Mock User' }]);
|
|
||||||
expect(listUsersRouteHandler).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('GET /users/:id', () => {
|
|
||||||
it('should return a single user', async () => {
|
|
||||||
const response = await request(app).get('/users/123');
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(response.body).toEqual({ id: '123', name: 'Mock User' });
|
|
||||||
expect(getUserRouteHandler).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -2,10 +2,6 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import dotenv from 'dotenv';
|
|
||||||
|
|
||||||
// Load environment variables from .env file
|
|
||||||
dotenv.config();
|
|
||||||
|
|
||||||
// Configure paths relative to this file
|
// Configure paths relative to this file
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
@ -29,47 +25,7 @@ function loadConfig() {
|
|||||||
throw new Error(`Configuration for environment '${NODE_ENV}' not found in config.json`);
|
throw new Error(`Configuration for environment '${NODE_ENV}' not found in config.json`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const envConfig = config[NODE_ENV];
|
return config[NODE_ENV];
|
||||||
|
|
||||||
// Ensure auth config exists
|
|
||||||
if (!envConfig.auth) {
|
|
||||||
envConfig.auth = {};
|
|
||||||
}
|
|
||||||
if (!envConfig.auth.keycloak) {
|
|
||||||
envConfig.auth.keycloak = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Override secrets with environment variables if available
|
|
||||||
if (process.env.KEYCLOAK_CLIENT_SECRET) {
|
|
||||||
envConfig.auth.keycloak.clientSecret = process.env.KEYCLOAK_CLIENT_SECRET;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Session secret must be set - use env var or throw error
|
|
||||||
if (process.env.SESSION_SECRET) {
|
|
||||||
envConfig.auth.sessionSecret = process.env.SESSION_SECRET;
|
|
||||||
} else if (!envConfig.auth.sessionSecret) {
|
|
||||||
throw new Error(
|
|
||||||
'SESSION_SECRET environment variable is required. Please set SESSION_SECRET in your environment.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure smtp config exists and override with env vars if available
|
|
||||||
if (!envConfig.smtp) {
|
|
||||||
envConfig.smtp = {};
|
|
||||||
}
|
|
||||||
if (process.env.SMTP_HOST) envConfig.smtp.host = process.env.SMTP_HOST;
|
|
||||||
if (process.env.SMTP_PORT) envConfig.smtp.port = parseInt(process.env.SMTP_PORT, 10);
|
|
||||||
if (process.env.SMTP_SECURE) envConfig.smtp.secure = process.env.SMTP_SECURE === 'true';
|
|
||||||
if (process.env.SMTP_USER || process.env.SMTP_PASS) {
|
|
||||||
envConfig.smtp.auth = {
|
|
||||||
...(envConfig.smtp.auth || {}),
|
|
||||||
...(process.env.SMTP_USER && { user: process.env.SMTP_USER }),
|
|
||||||
...(process.env.SMTP_PASS && { pass: process.env.SMTP_PASS }),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (process.env.SMTP_FROM) envConfig.smtp.from = process.env.SMTP_FROM;
|
|
||||||
|
|
||||||
return envConfig;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error loading config:', err);
|
console.error('Error loading config:', err);
|
||||||
throw err;
|
throw err;
|
||||||
|
|||||||
@ -1,196 +0,0 @@
|
|||||||
import { jest } from '@jest/globals';
|
|
||||||
|
|
||||||
// Mock src/database/utils.js (where generateId and convertObjectIdStringsInFilter live)
|
|
||||||
jest.unstable_mockModule('../utils.js', () => ({
|
|
||||||
generateId: jest.fn(() => () => 'test-id'),
|
|
||||||
convertObjectIdStringsInFilter: jest.fn((filter) => filter),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock src/utils.js (where most database.js helpers live)
|
|
||||||
jest.unstable_mockModule('../../utils.js', () => ({
|
|
||||||
deleteAuditLog: jest.fn(),
|
|
||||||
deleteNotification: jest.fn(),
|
|
||||||
distributeDelete: jest.fn(),
|
|
||||||
editAuditLog: jest.fn(),
|
|
||||||
editNotification: jest.fn(),
|
|
||||||
expandObjectIds: jest.fn((obj) => obj),
|
|
||||||
flatternObjectIds: jest.fn((object) => {
|
|
||||||
if (!object || typeof object !== 'object') {
|
|
||||||
return object;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = {};
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(object)) {
|
|
||||||
if (value && typeof value === 'object' && value._id) {
|
|
||||||
result[key] = value._id;
|
|
||||||
} else {
|
|
||||||
result[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}),
|
|
||||||
getFieldsByRef: jest.fn(() => []),
|
|
||||||
getQueryToCacheKey: jest.fn(({ model, id }) => `${model}:${id}`),
|
|
||||||
modelHasRef: jest.fn(() => false),
|
|
||||||
newAuditLog: jest.fn(),
|
|
||||||
distributeNew: jest.fn(),
|
|
||||||
distributeUpdate: jest.fn(),
|
|
||||||
distributeChildUpdate: jest.fn(),
|
|
||||||
distributeChildDelete: jest.fn(),
|
|
||||||
distributeChildNew: jest.fn(),
|
|
||||||
distributeStats: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.unstable_mockModule('../redis.js', () => ({
|
|
||||||
redisServer: {
|
|
||||||
getKey: jest.fn(),
|
|
||||||
setKey: jest.fn(),
|
|
||||||
deleteKey: jest.fn(),
|
|
||||||
getKeysByPattern: jest.fn(() => []), // Return empty array to avoid iterable error
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.unstable_mockModule('../../services/misc/model.js', () => ({
|
|
||||||
getAllModels: jest.fn(() => []),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.unstable_mockModule('../schemas/management/auditlog.schema.js', () => ({
|
|
||||||
auditLogModel: {
|
|
||||||
find: jest.fn(),
|
|
||||||
create: jest.fn(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock fileModel specifically as it's imported by database.js
|
|
||||||
jest.unstable_mockModule('../schemas/management/file.schema.js', () => ({
|
|
||||||
fileModel: {
|
|
||||||
findById: jest.fn(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Now import the database utilities
|
|
||||||
const { listObjects, getObject, newObject, editObject, deleteObject } = await import(
|
|
||||||
'../database.js'
|
|
||||||
);
|
|
||||||
|
|
||||||
describe('Database Utilities (CRUD)', () => {
|
|
||||||
let mockModel;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mockModel = {
|
|
||||||
modelName: 'TestModel',
|
|
||||||
find: jest.fn().mockReturnThis(),
|
|
||||||
findById: jest.fn().mockReturnThis(),
|
|
||||||
findByIdAndUpdate: jest.fn().mockReturnThis(),
|
|
||||||
findByIdAndDelete: jest.fn().mockReturnThis(),
|
|
||||||
create: jest.fn(),
|
|
||||||
sort: jest.fn().mockReturnThis(),
|
|
||||||
skip: jest.fn().mockReturnThis(),
|
|
||||||
limit: jest.fn().mockReturnThis(),
|
|
||||||
populate: jest.fn().mockReturnThis(),
|
|
||||||
select: jest.fn().mockReturnThis(),
|
|
||||||
lean: jest.fn().mockReturnThis(),
|
|
||||||
exec: jest.fn(),
|
|
||||||
};
|
|
||||||
jest.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('listObjects', () => {
|
|
||||||
it('should return a list of objects', async () => {
|
|
||||||
const mockData = [{ _id: '1', name: 'Test' }];
|
|
||||||
mockModel.lean.mockResolvedValue(mockData);
|
|
||||||
|
|
||||||
const result = await listObjects({ model: mockModel });
|
|
||||||
|
|
||||||
expect(mockModel.find).toHaveBeenCalled();
|
|
||||||
expect(result).toEqual(mockData);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle pagination', async () => {
|
|
||||||
await listObjects({ model: mockModel, page: 2, limit: 10 });
|
|
||||||
expect(mockModel.skip).toHaveBeenCalledWith(10);
|
|
||||||
expect(mockModel.limit).toHaveBeenCalledWith(10);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getObject', () => {
|
|
||||||
it('should return a single object by ID', async () => {
|
|
||||||
const mockData = { _id: '123', name: 'Test' };
|
|
||||||
mockModel.lean.mockResolvedValue(mockData);
|
|
||||||
|
|
||||||
const result = await getObject({ model: mockModel, id: '123' });
|
|
||||||
|
|
||||||
expect(mockModel.findById).toHaveBeenCalledWith('123');
|
|
||||||
expect(result).toEqual(mockData);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return 404 if object not found', async () => {
|
|
||||||
mockModel.lean.mockResolvedValue(null);
|
|
||||||
|
|
||||||
const result = await getObject({ model: mockModel, id: '123' });
|
|
||||||
|
|
||||||
expect(result).toEqual({ error: 'Object not found.', code: 404 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('newObject', () => {
|
|
||||||
it('should create a new object', async () => {
|
|
||||||
const newData = { name: 'New' };
|
|
||||||
const createdData = { _id: '456', ...newData };
|
|
||||||
mockModel.create.mockResolvedValue({
|
|
||||||
toObject: () => createdData,
|
|
||||||
_id: '456',
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await newObject({ model: mockModel, newData });
|
|
||||||
|
|
||||||
expect(mockModel.create).toHaveBeenCalledWith(newData);
|
|
||||||
expect(result).toEqual(createdData);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('editObject', () => {
|
|
||||||
it('should update an existing object', async () => {
|
|
||||||
const id = '123';
|
|
||||||
const updateData = { name: 'Updated' };
|
|
||||||
const previousData = { _id: id, name: 'Old' };
|
|
||||||
|
|
||||||
mockModel.lean.mockResolvedValue(previousData);
|
|
||||||
|
|
||||||
const result = await editObject({ model: mockModel, id, updateData });
|
|
||||||
|
|
||||||
expect(mockModel.findByIdAndUpdate).toHaveBeenCalledWith(id, updateData);
|
|
||||||
expect(result).toEqual({ ...previousData, ...updateData });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should flatten object id references before updating', async () => {
|
|
||||||
const id = '123';
|
|
||||||
const fileId = '507f1f77bcf86cd799439012';
|
|
||||||
const updateData = { file: { _id: fileId, name: 'part.gcode' } };
|
|
||||||
const previousData = { _id: id, name: 'Old' };
|
|
||||||
|
|
||||||
mockModel.lean.mockResolvedValue(previousData);
|
|
||||||
|
|
||||||
await editObject({ model: mockModel, id, updateData });
|
|
||||||
|
|
||||||
expect(mockModel.findByIdAndUpdate).toHaveBeenCalledWith(id, { file: fileId });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('deleteObject', () => {
|
|
||||||
it('should delete an object', async () => {
|
|
||||||
const id = '123';
|
|
||||||
const mockData = { _id: id, name: 'To be deleted' };
|
|
||||||
mockModel.findByIdAndDelete.mockResolvedValue({
|
|
||||||
toObject: () => mockData,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await deleteObject({ model: mockModel, id });
|
|
||||||
|
|
||||||
expect(mockModel.findByIdAndDelete).toHaveBeenCalledWith(id);
|
|
||||||
expect(result).toEqual({ deleted: true, object: mockData });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,10 +1,11 @@
|
|||||||
import mongoose from 'mongoose';
|
import mongoose from "mongoose";
|
||||||
import { userModel } from '../schemas/user.schema.js';
|
import { userModel } from "../schemas/user.schema.js";
|
||||||
import { dbConnect } from '../mongo/index.js';
|
import { dbConnect } from "../mongo/index.js";
|
||||||
|
|
||||||
async function clear() {
|
async function clear() {
|
||||||
dbConnect();
|
dbConnect();
|
||||||
await userModel.deleteMany({});
|
await userModel.deleteMany({});
|
||||||
|
console.log("DB cleared");
|
||||||
}
|
}
|
||||||
|
|
||||||
clear().then(() => {
|
clear().then(() => {
|
||||||
|
|||||||
@ -1,54 +0,0 @@
|
|||||||
/**
|
|
||||||
* Convert a value to a CSV cell-friendly format.
|
|
||||||
* Primitives pass through; objects/arrays are stringified; dates are formatted.
|
|
||||||
*/
|
|
||||||
function toCsvValue(val) {
|
|
||||||
if (val === null || val === undefined) return '';
|
|
||||||
if (val instanceof Date) return val.toISOString();
|
|
||||||
if (typeof val === 'number' || typeof val === 'boolean') return String(val);
|
|
||||||
if (typeof val === 'string') return val;
|
|
||||||
if (typeof val === 'object') {
|
|
||||||
if (Array.isArray(val)) return val.map(toCsvValue).join(', ');
|
|
||||||
return JSON.stringify(val);
|
|
||||||
}
|
|
||||||
return String(val);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Escape a CSV field per RFC 4180: wrap in double quotes, escape internal quotes by doubling.
|
|
||||||
*/
|
|
||||||
function escapeCsvField(str) {
|
|
||||||
if (str == null) return '""';
|
|
||||||
const s = String(str);
|
|
||||||
if (s.includes('"') || s.includes(',') || s.includes('\n') || s.includes('\r')) {
|
|
||||||
return '"' + s.replace(/"/g, '""') + '"';
|
|
||||||
}
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a CSV buffer from tabular data.
|
|
||||||
* @param {Array<Object>} data - Array of row objects (keys = column headers)
|
|
||||||
* @param {Object} options - Options
|
|
||||||
* @param {string[]} [options.columnOrder] - Optional column order (uses Object.keys of first row if not provided)
|
|
||||||
* @returns {Buffer} CSV file as buffer
|
|
||||||
*/
|
|
||||||
export function generateCsvTable(data, options = {}) {
|
|
||||||
const { columnOrder } = options;
|
|
||||||
|
|
||||||
if (!data || data.length === 0) {
|
|
||||||
return Buffer.from('', 'utf8');
|
|
||||||
}
|
|
||||||
|
|
||||||
const keys = columnOrder || Object.keys(data[0]).filter((k) => !k.startsWith('@'));
|
|
||||||
const headerRow = keys.map((k) => escapeCsvField(k)).join(',');
|
|
||||||
const lines = [headerRow];
|
|
||||||
|
|
||||||
for (const row of data) {
|
|
||||||
const values = keys.map((key) => escapeCsvField(toCsvValue(row[key])));
|
|
||||||
lines.push(values.join(','));
|
|
||||||
}
|
|
||||||
|
|
||||||
const csv = lines.join('\n');
|
|
||||||
return Buffer.from(csv, 'utf8');
|
|
||||||
}
|
|
||||||
@ -1,11 +1,6 @@
|
|||||||
import config from '../config.js';
|
import config from '../config.js';
|
||||||
import { fileModel } from './schemas/management/file.schema.js';
|
import { fileModel } from './schemas/management/file.schema.js';
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import log4js from 'log4js';
|
|
||||||
import crypto from 'crypto';
|
|
||||||
import sharp from 'sharp';
|
|
||||||
import { encode as encodeBlurHash } from 'blurhash';
|
|
||||||
import { uploadFile, deleteFile, downloadFile, BUCKETS } from './ceph.js';
|
|
||||||
import {
|
import {
|
||||||
deleteAuditLog,
|
deleteAuditLog,
|
||||||
distributeDelete,
|
distributeDelete,
|
||||||
@ -13,6 +8,9 @@ import {
|
|||||||
modelHasRef,
|
modelHasRef,
|
||||||
getFieldsByRef,
|
getFieldsByRef,
|
||||||
getQueryToCacheKey,
|
getQueryToCacheKey,
|
||||||
|
} from '../utils.js';
|
||||||
|
import log4js from 'log4js';
|
||||||
|
import {
|
||||||
editAuditLog,
|
editAuditLog,
|
||||||
distributeUpdate,
|
distributeUpdate,
|
||||||
newAuditLog,
|
newAuditLog,
|
||||||
@ -21,14 +19,10 @@ import {
|
|||||||
distributeChildDelete,
|
distributeChildDelete,
|
||||||
distributeChildNew,
|
distributeChildNew,
|
||||||
distributeStats,
|
distributeStats,
|
||||||
editNotification,
|
|
||||||
deleteNotification,
|
|
||||||
flatternObjectIds,
|
|
||||||
} from '../utils.js';
|
} from '../utils.js';
|
||||||
import { getAllModels } from '../services/misc/model.js';
|
import { getAllModels } from '../services/misc/model.js';
|
||||||
import { redisServer } from './redis.js';
|
import { redisServer } from './redis.js';
|
||||||
import { auditLogModel } from './schemas/management/auditlog.schema.js';
|
import { auditLogModel } from './schemas/management/auditlog.schema.js';
|
||||||
import { convertObjectIdStringsInFilter } from './utils.js';
|
|
||||||
|
|
||||||
const logger = log4js.getLogger('Database');
|
const logger = log4js.getLogger('Database');
|
||||||
logger.level = config.server.logLevel;
|
logger.level = config.server.logLevel;
|
||||||
@ -37,16 +31,6 @@ const cacheLogger = log4js.getLogger('DatabaseCache');
|
|||||||
cacheLogger.level = config.server.logLevel;
|
cacheLogger.level = config.server.logLevel;
|
||||||
|
|
||||||
const CACHE_TTL_SECONDS = parseInt(config.database.redis.cacheTtl || '30', 10);
|
const CACHE_TTL_SECONDS = parseInt(config.database.redis.cacheTtl || '30', 10);
|
||||||
const NEIGHBORS_CACHE_TTL_SECONDS = 60;
|
|
||||||
const NEIGHBORS_CACHE_WINDOW = 25;
|
|
||||||
const NEIGHBORS_CACHE_PREFIX = 'neighbors';
|
|
||||||
|
|
||||||
const mergeObjectUpdates = (target, source) =>
|
|
||||||
_.mergeWith(target, source, (objValue, srcValue) => {
|
|
||||||
if (Array.isArray(objValue) || Array.isArray(srcValue)) {
|
|
||||||
return srcValue;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export const retrieveObjectCache = async ({ model, id, populate = [] }) => {
|
export const retrieveObjectCache = async ({ model, id, populate = [] }) => {
|
||||||
if (!model || !id) return undefined;
|
if (!model || !id) return undefined;
|
||||||
@ -90,13 +74,13 @@ export const updateObjectCache = async ({ model, id, object, populate = [] }) =>
|
|||||||
for (const key of matchingKeys) {
|
for (const key of matchingKeys) {
|
||||||
logger.trace('Updating object cache:', key);
|
logger.trace('Updating object cache:', key);
|
||||||
const cachedObject = (await redisServer.getKey(key)) || {};
|
const cachedObject = (await redisServer.getKey(key)) || {};
|
||||||
const mergedObject = mergeObjectUpdates(cachedObject, object);
|
const mergedObject = _.merge(cachedObject, object);
|
||||||
await redisServer.setKey(key, mergedObject, CACHE_TTL_SECONDS);
|
await redisServer.setKey(key, mergedObject, CACHE_TTL_SECONDS);
|
||||||
mergedObjects.push(mergedObject);
|
mergedObjects.push(mergedObject);
|
||||||
}
|
}
|
||||||
|
|
||||||
const cacheObject = (await redisServer.getKey(cacheKey)) || {};
|
const cacheObject = (await redisServer.getKey(cacheKey)) || {};
|
||||||
const mergedObject = mergeObjectUpdates(cacheObject, object);
|
const mergedObject = _.merge(cacheObject, object);
|
||||||
await redisServer.setKey(cacheKey, mergedObject, CACHE_TTL_SECONDS);
|
await redisServer.setKey(cacheKey, mergedObject, CACHE_TTL_SECONDS);
|
||||||
|
|
||||||
cacheLogger.trace('Updated object cache:', {
|
cacheLogger.trace('Updated object cache:', {
|
||||||
@ -137,119 +121,6 @@ export const deleteObjectCache = async ({ model, id }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildNeighborsQueryFilter = (filter = {}, search = '') => {
|
|
||||||
const queryFilter = { ...filter };
|
|
||||||
|
|
||||||
Object.keys(queryFilter).forEach((key) => {
|
|
||||||
if (key.endsWith('._id')) {
|
|
||||||
const baseKey = key.slice(0, -4);
|
|
||||||
queryFilter[baseKey] = queryFilter[key];
|
|
||||||
delete queryFilter[key];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (search) {
|
|
||||||
const isRefOrIdSearch = search.match(/^.{3}:/);
|
|
||||||
if (isRefOrIdSearch) {
|
|
||||||
const lookupValue = search.split(':')[1];
|
|
||||||
if (lookupValue.length <= 12) {
|
|
||||||
queryFilter._reference = lookupValue;
|
|
||||||
} else {
|
|
||||||
queryFilter._id = lookupValue;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
queryFilter.$text = { $search: search };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return queryFilter;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getNeighborsCacheKey = ({
|
|
||||||
model,
|
|
||||||
id,
|
|
||||||
filter = {},
|
|
||||||
search = '',
|
|
||||||
sort = '',
|
|
||||||
order = 'ascend',
|
|
||||||
}) => {
|
|
||||||
const normalizedSort = sort || 'createdAt';
|
|
||||||
const normalizedOrder = order || 'ascend';
|
|
||||||
const queryHash = crypto
|
|
||||||
.createHash('sha256')
|
|
||||||
.update(
|
|
||||||
JSON.stringify({
|
|
||||||
filter,
|
|
||||||
search,
|
|
||||||
sort: normalizedSort,
|
|
||||||
order: normalizedOrder,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.digest('hex');
|
|
||||||
|
|
||||||
return `${NEIGHBORS_CACHE_PREFIX}:${model.modelName}:${id?.toString()}:${queryHash}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const neighborsCacheToResponse = (cached) =>
|
|
||||||
expandObjectIds({
|
|
||||||
next: cached.next[0] ? { _id: cached.next[0] } : null,
|
|
||||||
previous: cached.previous[0] ? { _id: cached.previous[0] } : null,
|
|
||||||
});
|
|
||||||
|
|
||||||
const buildNeighborsCacheValue = (ids, index) => {
|
|
||||||
const previous = ids
|
|
||||||
.slice(Math.max(0, index - NEIGHBORS_CACHE_WINDOW), index)
|
|
||||||
.map((neighborId) => String(neighborId))
|
|
||||||
.reverse();
|
|
||||||
const next = ids
|
|
||||||
.slice(index + 1, index + 1 + NEIGHBORS_CACHE_WINDOW)
|
|
||||||
.map((neighborId) => String(neighborId));
|
|
||||||
|
|
||||||
return { previous, next };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const invalidateNeighborsCacheForObject = async ({ model, id }) => {
|
|
||||||
if (!model || !id) return;
|
|
||||||
|
|
||||||
const objectId = id?.toString();
|
|
||||||
const cacheKeyPrefix = `${NEIGHBORS_CACHE_PREFIX}:${model.modelName}:`;
|
|
||||||
const cacheKeyForObject = `${cacheKeyPrefix}${objectId}:`;
|
|
||||||
|
|
||||||
cacheLogger.trace('Invalidating neighbors cache for object:', {
|
|
||||||
model: model.modelName,
|
|
||||||
id: objectId,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const matchingKeys = await redisServer.getKeysByPattern(`${cacheKeyPrefix}*`);
|
|
||||||
|
|
||||||
for (const cacheKey of matchingKeys) {
|
|
||||||
if (cacheKey.startsWith(cacheKeyForObject)) {
|
|
||||||
await redisServer.deleteKey(cacheKey);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cached = await redisServer.getKey(cacheKey);
|
|
||||||
if (!cached) continue;
|
|
||||||
|
|
||||||
const containsId =
|
|
||||||
(Array.isArray(cached.previous) && cached.previous.includes(objectId)) ||
|
|
||||||
(Array.isArray(cached.next) && cached.next.includes(objectId));
|
|
||||||
|
|
||||||
if (containsId) {
|
|
||||||
await redisServer.deleteKey(cacheKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cacheLogger.trace('Invalidated neighbors cache for object:', {
|
|
||||||
model: model.modelName,
|
|
||||||
id: objectId,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
cacheLogger.error('Error invalidating neighbors cache:', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Utility to run one or many rollup aggregations in a single query via $facet.
|
// Utility to run one or many rollup aggregations in a single query via $facet.
|
||||||
export const aggregateRollups = async ({ model, baseFilter = {}, rollupConfigs = [] }) => {
|
export const aggregateRollups = async ({ model, baseFilter = {}, rollupConfigs = [] }) => {
|
||||||
if (!rollupConfigs.length) {
|
if (!rollupConfigs.length) {
|
||||||
@ -478,14 +349,12 @@ export const listObjects = async ({
|
|||||||
filter = {},
|
filter = {},
|
||||||
sort = '',
|
sort = '',
|
||||||
order = 'ascend',
|
order = 'ascend',
|
||||||
pagination = true,
|
|
||||||
project, // optional: override default projection
|
project, // optional: override default projection
|
||||||
}) => {
|
}) => {
|
||||||
try {
|
try {
|
||||||
logger.trace('Listing object:', {
|
logger.trace('Listing object:', {
|
||||||
model,
|
model,
|
||||||
populate,
|
populate,
|
||||||
pagination,
|
|
||||||
page,
|
page,
|
||||||
limit,
|
limit,
|
||||||
filter,
|
filter,
|
||||||
@ -494,7 +363,7 @@ export const listObjects = async ({
|
|||||||
project,
|
project,
|
||||||
});
|
});
|
||||||
// Calculate the skip value based on the page number and limit
|
// Calculate the skip value based on the page number and limit
|
||||||
const skip = pagination ? (page - 1) * limit : 0;
|
const skip = (page - 1) * limit;
|
||||||
// Fix: descend should be -1, ascend should be 1
|
// Fix: descend should be -1, ascend should be 1
|
||||||
const sortOrder = order === 'descend' ? -1 : 1;
|
const sortOrder = order === 'descend' ? -1 : 1;
|
||||||
|
|
||||||
@ -502,6 +371,10 @@ export const listObjects = async ({
|
|||||||
sort = 'createdAt';
|
sort = 'createdAt';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (filter) {
|
||||||
|
console.log('filter', filter);
|
||||||
|
}
|
||||||
|
|
||||||
// Translate any key ending with ._id to remove the ._id suffix for Mongoose
|
// Translate any key ending with ._id to remove the ._id suffix for Mongoose
|
||||||
Object.keys(filter).forEach((key) => {
|
Object.keys(filter).forEach((key) => {
|
||||||
if (key.endsWith('._id')) {
|
if (key.endsWith('._id')) {
|
||||||
@ -516,7 +389,7 @@ export const listObjects = async ({
|
|||||||
.find(filter)
|
.find(filter)
|
||||||
.sort({ [sort]: sortOrder })
|
.sort({ [sort]: sortOrder })
|
||||||
.skip(skip)
|
.skip(skip)
|
||||||
.limit(pagination ? Number(limit) : undefined);
|
.limit(Number(limit));
|
||||||
|
|
||||||
// Handle populate (array or single value)
|
// Handle populate (array or single value)
|
||||||
if (populate) {
|
if (populate) {
|
||||||
@ -544,91 +417,6 @@ export const listObjects = async ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getObjectNeighbors = async ({
|
|
||||||
model,
|
|
||||||
id,
|
|
||||||
filter = {},
|
|
||||||
search = '',
|
|
||||||
sort = '',
|
|
||||||
order = 'ascend',
|
|
||||||
}) => {
|
|
||||||
try {
|
|
||||||
const sortOrder = order === 'descend' ? -1 : 1;
|
|
||||||
const sortField = sort || 'createdAt';
|
|
||||||
const cacheKey = getNeighborsCacheKey({ model, id, filter, search, sort: sortField, order });
|
|
||||||
|
|
||||||
cacheLogger.trace('Retrieving neighbors from cache:', {
|
|
||||||
model: model.modelName,
|
|
||||||
id: id?.toString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const cached = await redisServer.getKey(cacheKey);
|
|
||||||
if (cached) {
|
|
||||||
cacheLogger.trace('Neighbors cache hit:', {
|
|
||||||
model: model.modelName,
|
|
||||||
id: id?.toString(),
|
|
||||||
});
|
|
||||||
return neighborsCacheToResponse(cached);
|
|
||||||
}
|
|
||||||
|
|
||||||
cacheLogger.trace('Neighbors cache miss:', {
|
|
||||||
model: model.modelName,
|
|
||||||
id: id?.toString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const queryFilter = buildNeighborsQueryFilter(filter, search);
|
|
||||||
|
|
||||||
const results = await model
|
|
||||||
.find(queryFilter)
|
|
||||||
.sort({ [sortField]: sortOrder })
|
|
||||||
.select('_id')
|
|
||||||
.lean();
|
|
||||||
|
|
||||||
const ids = results.map((doc) => doc._id);
|
|
||||||
const index = ids.findIndex((docId) => String(docId) === String(id));
|
|
||||||
|
|
||||||
if (index === -1) {
|
|
||||||
return { next: null, previous: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
const cacheValue = buildNeighborsCacheValue(ids, index);
|
|
||||||
await redisServer.setKey(cacheKey, cacheValue, NEIGHBORS_CACHE_TTL_SECONDS);
|
|
||||||
|
|
||||||
return neighborsCacheToResponse(cacheValue);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Object neighbors error:', error);
|
|
||||||
return { error: error, code: 500 };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const searchObjects = async ({ model, search, populate = [] }) => {
|
|
||||||
try {
|
|
||||||
const isRefOrIdSearch = search.match(/^.{3}:/);
|
|
||||||
let query = null;
|
|
||||||
if (isRefOrIdSearch) {
|
|
||||||
const lookupValue = search.split(':')[1];
|
|
||||||
if (lookupValue.length <= 12) {
|
|
||||||
query = model.find({ _reference: lookupValue });
|
|
||||||
} else {
|
|
||||||
query = model.find({ _id: lookupValue });
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
query = model.find({ $text: { $search: search } });
|
|
||||||
}
|
|
||||||
if (populate) {
|
|
||||||
for (const pop of populate) {
|
|
||||||
query = query.populate(pop);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const queryResult = await query.lean();
|
|
||||||
const result = queryResult.map((item) => expandObjectIds(item));
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Object search error:', error);
|
|
||||||
return { error: error, code: 500 };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// New function to list unique property values
|
// New function to list unique property values
|
||||||
export const listPropertyValues = async ({ model, property, filter = {}, search = '' }) => {
|
export const listPropertyValues = async ({ model, property, filter = {}, search = '' }) => {
|
||||||
let aggregateCommand = [];
|
let aggregateCommand = [];
|
||||||
@ -651,13 +439,6 @@ export const listPropertyValues = async ({ model, property, filter = {}, search
|
|||||||
return await model.aggregate(aggregateCommand);
|
return await model.aggregate(aggregateCommand);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getPropertyValues = async ({ model, property }) => {
|
|
||||||
if (!property) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return await model.distinct(property);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper to build nested structure for listObjectsByProperty
|
// Helper to build nested structure for listObjectsByProperty
|
||||||
function nestGroups(groups, props, filter, idx = 0) {
|
function nestGroups(groups, props, filter, idx = 0) {
|
||||||
if (idx >= props.length) return groups;
|
if (idx >= props.length) return groups;
|
||||||
@ -696,6 +477,8 @@ function nestGroups(groups, props, filter, idx = 0) {
|
|||||||
// Check if any group in this key matches the filter (by _id or name)
|
// Check if any group in this key matches the filter (by _id or name)
|
||||||
const matches = groupList.filter((group) => {
|
const matches = groupList.filter((group) => {
|
||||||
const { filterVals } = getKeyAndFilterVals(group._id[prop]);
|
const { filterVals } = getKeyAndFilterVals(group._id[prop]);
|
||||||
|
console.log('filterVals', filterVals);
|
||||||
|
console.log('filterValue', filterValue);
|
||||||
return filterVals.some((val) => val?.toString() === filterValue);
|
return filterVals.some((val) => val?.toString() === filterValue);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -739,6 +522,7 @@ export const listObjectsByProperties = async ({
|
|||||||
populate,
|
populate,
|
||||||
}) => {
|
}) => {
|
||||||
try {
|
try {
|
||||||
|
console.log('Props', properties);
|
||||||
const propertiesPresent = !(
|
const propertiesPresent = !(
|
||||||
!Array.isArray(properties) ||
|
!Array.isArray(properties) ||
|
||||||
properties.length === 0 ||
|
properties.length === 0 ||
|
||||||
@ -748,15 +532,6 @@ export const listObjectsByProperties = async ({
|
|||||||
// Build aggregation pipeline
|
// Build aggregation pipeline
|
||||||
const pipeline = [];
|
const pipeline = [];
|
||||||
|
|
||||||
logger.debug('Master filter:', masterFilter);
|
|
||||||
|
|
||||||
// Match before populate so reference fields (e.g. filament) are still ObjectIds
|
|
||||||
if (Object.keys(masterFilter).length > 0) {
|
|
||||||
const convertedFilter = convertObjectIdStringsInFilter(masterFilter);
|
|
||||||
logger.debug('Converted filter:', convertedFilter);
|
|
||||||
pipeline.push({ $match: convertedFilter });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle populate (array or single value)
|
// Handle populate (array or single value)
|
||||||
if (populate) {
|
if (populate) {
|
||||||
const populates = Array.isArray(populate) ? populate : [populate];
|
const populates = Array.isArray(populate) ? populate : [populate];
|
||||||
@ -800,6 +575,10 @@ export const listObjectsByProperties = async ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (masterFilter != {}) {
|
||||||
|
pipeline.push({ $match: { ...masterFilter } });
|
||||||
|
}
|
||||||
|
|
||||||
if (propertiesPresent) {
|
if (propertiesPresent) {
|
||||||
// Build the $group _id object for all properties
|
// Build the $group _id object for all properties
|
||||||
const groupId = {};
|
const groupId = {};
|
||||||
@ -815,11 +594,12 @@ export const listObjectsByProperties = async ({
|
|||||||
|
|
||||||
// Run aggregation
|
// Run aggregation
|
||||||
const results = await model.aggregate(pipeline);
|
const results = await model.aggregate(pipeline);
|
||||||
|
console.log('results', results);
|
||||||
return nestGroups(results, properties, filter);
|
return nestGroups(results, properties, filter);
|
||||||
} else {
|
} else {
|
||||||
// If no properties specified, just return all objects without grouping
|
// If no properties specified, just return all objects without grouping
|
||||||
// Ensure pipeline is not empty by adding a $match stage if needed
|
// Ensure pipeline is not empty by adding a $match stage if needed
|
||||||
if (pipeline.length === 0 && Object.keys(masterFilter).length === 0) {
|
if (pipeline.length === 0) {
|
||||||
pipeline.push({ $match: {} });
|
pipeline.push({ $match: {} });
|
||||||
}
|
}
|
||||||
const results = await model.aggregate(pipeline);
|
const results = await model.aggregate(pipeline);
|
||||||
@ -951,30 +731,13 @@ export const listObjectDependencies = async ({ model, id }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const checkStates = async ({ model, id, states }) => {
|
|
||||||
try {
|
|
||||||
const object = await getObject({ model, id, cached: true });
|
|
||||||
if (!object?.state?.type) {
|
|
||||||
logger.warn(`Object ${id} has no state type.`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (states.includes(object?.state?.type)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('checkStates error:', error);
|
|
||||||
return { error: error.message, code: 500 };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Reusable function to edit an object by ID, with audit logging and distribution
|
// Reusable function to edit an object by ID, with audit logging and distribution
|
||||||
export const editObject = async ({ model, id, updateData, user, populate, recalculate = true }) => {
|
export const editObject = async ({ model, id, updateData, user, populate }) => {
|
||||||
try {
|
try {
|
||||||
// Determine parentType from model name
|
// Determine parentType from model name
|
||||||
const parentType = model.modelName ? model.modelName : 'unknown';
|
const parentType = model.modelName ? model.modelName : 'unknown';
|
||||||
// Fetch the and update object
|
// Fetch the and update object
|
||||||
var query = model.findByIdAndUpdate(id, flatternObjectIds(updateData)).lean();
|
var query = model.findByIdAndUpdate(id, updateData).lean();
|
||||||
|
|
||||||
if (populate) {
|
if (populate) {
|
||||||
if (Array.isArray(populate)) {
|
if (Array.isArray(populate)) {
|
||||||
@ -1020,25 +783,24 @@ export const editObject = async ({ model, id, updateData, user, populate, recalc
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedObject = mergeObjectUpdates(
|
|
||||||
_.cloneDeep(previousExpandedObject),
|
|
||||||
expandObjectIds(updateData)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Audit log before update
|
// Audit log before update
|
||||||
await editAuditLog(previousExpandedObject, updatedObject, id, parentType, user);
|
await editAuditLog(
|
||||||
|
previousExpandedObject,
|
||||||
if (
|
{ ...previousExpandedObject, ...updateData },
|
||||||
parentType !== 'notification' &&
|
id,
|
||||||
parentType !== 'auditLog' &&
|
parentType,
|
||||||
parentType !== 'userNotifier'
|
user
|
||||||
) {
|
);
|
||||||
await editNotification(previousExpandedObject, updatedObject, id, parentType, user);
|
|
||||||
}
|
|
||||||
// Distribute update
|
// Distribute update
|
||||||
await distributeUpdate(updateData, id, parentType);
|
await distributeUpdate(updateData, id, parentType);
|
||||||
// Call childUpdate event for any child objects
|
// Call childUpdate event for any child objects
|
||||||
await distributeChildUpdate(previousExpandedObject, updatedObject, id, model);
|
await distributeChildUpdate(
|
||||||
|
previousExpandedObject,
|
||||||
|
{ ...previousExpandedObject, ...updateData },
|
||||||
|
id,
|
||||||
|
model
|
||||||
|
);
|
||||||
|
const updatedObject = { ...previousExpandedObject, ...updateData };
|
||||||
|
|
||||||
// Update cache with the new version
|
// Update cache with the new version
|
||||||
await updateObjectCache({
|
await updateObjectCache({
|
||||||
@ -1047,9 +809,8 @@ export const editObject = async ({ model, id, updateData, user, populate, recalc
|
|||||||
object: updatedObject,
|
object: updatedObject,
|
||||||
populate,
|
populate,
|
||||||
});
|
});
|
||||||
await invalidateNeighborsCacheForObject({ model, id });
|
|
||||||
|
|
||||||
if (model.recalculate && recalculate == true) {
|
if (model.recalculate) {
|
||||||
logger.debug(`Recalculating ${model.modelName}`);
|
logger.debug(`Recalculating ${model.modelName}`);
|
||||||
await model.recalculate(updatedObject, user);
|
await model.recalculate(updatedObject, user);
|
||||||
}
|
}
|
||||||
@ -1067,33 +828,6 @@ export const editObject = async ({ model, id, updateData, user, populate, recalc
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Reusable function to edit multiple objects
|
|
||||||
export const editObjects = async ({ model, updates, user, populate, recalculate = true }) => {
|
|
||||||
try {
|
|
||||||
const results = [];
|
|
||||||
for (const update of updates) {
|
|
||||||
const id = update._id || update.id;
|
|
||||||
const updateData = { ...update };
|
|
||||||
delete updateData._id;
|
|
||||||
delete updateData.id;
|
|
||||||
|
|
||||||
const result = await editObject({
|
|
||||||
model,
|
|
||||||
id,
|
|
||||||
updateData,
|
|
||||||
user,
|
|
||||||
populate,
|
|
||||||
recalculate,
|
|
||||||
});
|
|
||||||
results.push(result);
|
|
||||||
}
|
|
||||||
return results;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('editObjects error:', error);
|
|
||||||
return { error: error.message, code: 500 };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Reusable function to create a new object
|
// Reusable function to create a new object
|
||||||
export const newObject = async ({ model, newData, user = null }, distributeChanges = true) => {
|
export const newObject = async ({ model, newData, user = null }, distributeChanges = true) => {
|
||||||
try {
|
try {
|
||||||
@ -1106,7 +840,6 @@ export const newObject = async ({ model, newData, user = null }, distributeChang
|
|||||||
const created = expandObjectIds(result.toObject());
|
const created = expandObjectIds(result.toObject());
|
||||||
|
|
||||||
await newAuditLog(newData, created._id, parentType, user);
|
await newAuditLog(newData, created._id, parentType, user);
|
||||||
|
|
||||||
if (distributeChanges == true) {
|
if (distributeChanges == true) {
|
||||||
await distributeNew(created, parentType);
|
await distributeNew(created, parentType);
|
||||||
}
|
}
|
||||||
@ -1138,240 +871,10 @@ export const newObject = async ({ model, newData, user = null }, distributeChang
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const FILE_THUMBNAIL_SIZES = [64, 128, 265];
|
|
||||||
const FILE_THUMBNAIL_CACHE_TTL_SECONDS = 5 * 60;
|
|
||||||
|
|
||||||
function getFileThumbnailCacheKey(fileId, size) {
|
|
||||||
return `thumbnails:${size}:${fileId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFileThumbnailS3Key(fileId, size) {
|
|
||||||
return `thumbnails/${size}/${fileId}.jpg`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function streamToBuffer(body) {
|
|
||||||
if (Buffer.isBuffer(body)) {
|
|
||||||
return body;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (body && typeof body.transformToByteArray === 'function') {
|
|
||||||
return Buffer.from(await body.transformToByteArray());
|
|
||||||
}
|
|
||||||
|
|
||||||
const chunks = [];
|
|
||||||
for await (const chunk of body) {
|
|
||||||
chunks.push(chunk);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Buffer.concat(chunks);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getThumbnail({ fileId, size, file }) {
|
|
||||||
const parsedSize = Number(size);
|
|
||||||
|
|
||||||
if (!FILE_THUMBNAIL_SIZES.includes(parsedSize)) {
|
|
||||||
return { error: 'Invalid thumbnail size.', code: 400 };
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedFileId = fileId.toString();
|
|
||||||
const cacheKey = getFileThumbnailCacheKey(normalizedFileId, parsedSize);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const cached = await redisServer.getKey(cacheKey);
|
|
||||||
if (cached) {
|
|
||||||
return {
|
|
||||||
buffer: Buffer.from(cached, 'base64'),
|
|
||||||
contentType: 'image/jpeg',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const s3Key =
|
|
||||||
file?.metaData?.thumbnails?.[parsedSize] ||
|
|
||||||
file?.metaData?.thumbnails?.[String(parsedSize)] ||
|
|
||||||
getFileThumbnailS3Key(normalizedFileId, parsedSize);
|
|
||||||
|
|
||||||
const body = await downloadFile(BUCKETS.FILES, s3Key);
|
|
||||||
const buffer = await streamToBuffer(body);
|
|
||||||
|
|
||||||
await redisServer.setKey(cacheKey, buffer.toString('base64'), FILE_THUMBNAIL_CACHE_TTL_SECONDS);
|
|
||||||
|
|
||||||
return {
|
|
||||||
buffer,
|
|
||||||
contentType: 'image/jpeg',
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
if (error.message === 'File not found') {
|
|
||||||
return { error: 'Thumbnail not found.', code: 404 };
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.error(
|
|
||||||
`Error getting thumbnail for file ${normalizedFileId} (size ${parsedSize}):`,
|
|
||||||
error
|
|
||||||
);
|
|
||||||
return { error: error.message, code: 500 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteFileThumbnails(fileObject) {
|
|
||||||
if (!fileObject?._id) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasStoredThumbnails =
|
|
||||||
fileObject.hasThumbnails ||
|
|
||||||
fileObject.metaData?.thumbnails ||
|
|
||||||
fileObject.type?.startsWith('image/');
|
|
||||||
|
|
||||||
if (!hasStoredThumbnails) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fileId = fileObject._id.toString();
|
|
||||||
const s3Keys = new Set(FILE_THUMBNAIL_SIZES.map((size) => getFileThumbnailS3Key(fileId, size)));
|
|
||||||
|
|
||||||
if (fileObject.metaData?.thumbnails) {
|
|
||||||
for (const key of Object.values(fileObject.metaData.thumbnails)) {
|
|
||||||
if (typeof key === 'string') {
|
|
||||||
s3Keys.add(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const s3Key of s3Keys) {
|
|
||||||
try {
|
|
||||||
await deleteFile(BUCKETS.FILES, s3Key);
|
|
||||||
logger.debug(`Deleted thumbnail from S3: ${s3Key}`);
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn(`Failed to delete thumbnail from S3 (${s3Key}):`, error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const size of FILE_THUMBNAIL_SIZES) {
|
|
||||||
try {
|
|
||||||
await redisServer.deleteKey(getFileThumbnailCacheKey(fileId, size));
|
|
||||||
logger.debug(`Deleted thumbnail from Redis: ${getFileThumbnailCacheKey(fileId, size)}`);
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn(
|
|
||||||
`Failed to delete thumbnail from Redis (${getFileThumbnailCacheKey(fileId, size)}):`,
|
|
||||||
error.message
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createFileThumbnails(fileData, fileObject) {
|
|
||||||
try {
|
|
||||||
const extension = fileObject?.extension?.toLowerCase();
|
|
||||||
const isGCodeFileType = extension === '.gcode' || extension === '.g';
|
|
||||||
const gcodeThumbnail = fileObject?.metaData?.thumbnail;
|
|
||||||
const hasGCodeThumbnailMeta =
|
|
||||||
isGCodeFileType && gcodeThumbnail && typeof gcodeThumbnail.data === 'string';
|
|
||||||
const isImageFileType = fileObject?.type?.startsWith('image/');
|
|
||||||
|
|
||||||
const hasThumbnailSource =
|
|
||||||
(hasGCodeThumbnailMeta && gcodeThumbnail.data.length > 0) ||
|
|
||||||
(isImageFileType && fileData);
|
|
||||||
|
|
||||||
if (!hasThumbnailSource || !fileObject?._id) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let thumbnailData = fileData;
|
|
||||||
if (hasGCodeThumbnailMeta) {
|
|
||||||
const normalized = gcodeThumbnail.data
|
|
||||||
.replace(/^data:image\/\w+;base64,/, '')
|
|
||||||
.replace(/\s/g, '');
|
|
||||||
thumbnailData = Buffer.from(normalized, 'base64');
|
|
||||||
if (!thumbnailData.length) return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fileId = fileObject._id.toString();
|
|
||||||
const image = sharp(thumbnailData, { failOn: 'none' });
|
|
||||||
const imageMetadata = await image.metadata();
|
|
||||||
|
|
||||||
if (!imageMetadata.width || !imageMetadata.height) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const blurhashSource = await sharp(thumbnailData)
|
|
||||||
.rotate()
|
|
||||||
.resize(32, 32, { fit: 'inside' })
|
|
||||||
.ensureAlpha()
|
|
||||||
.raw()
|
|
||||||
.toBuffer({ resolveWithObject: true });
|
|
||||||
|
|
||||||
const blurHash = encodeBlurHash(
|
|
||||||
new Uint8ClampedArray(blurhashSource.data),
|
|
||||||
blurhashSource.info.width,
|
|
||||||
blurhashSource.info.height,
|
|
||||||
4,
|
|
||||||
4
|
|
||||||
);
|
|
||||||
|
|
||||||
const thumbnails = {};
|
|
||||||
|
|
||||||
for (const size of FILE_THUMBNAIL_SIZES) {
|
|
||||||
const thumbnailBuffer = await sharp(thumbnailData)
|
|
||||||
.rotate()
|
|
||||||
.resize(size, size, { fit: 'inside', withoutEnlargement: true })
|
|
||||||
.jpeg({ quality: 85 })
|
|
||||||
.toBuffer();
|
|
||||||
|
|
||||||
const s3Key = getFileThumbnailS3Key(fileId, size);
|
|
||||||
await uploadFile(BUCKETS.FILES, s3Key, thumbnailBuffer, 'image/jpeg', {
|
|
||||||
fileId,
|
|
||||||
thumbnailSize: String(size),
|
|
||||||
});
|
|
||||||
|
|
||||||
await redisServer.setKey(
|
|
||||||
getFileThumbnailCacheKey(fileId, size),
|
|
||||||
thumbnailBuffer.toString('base64'),
|
|
||||||
FILE_THUMBNAIL_CACHE_TTL_SECONDS
|
|
||||||
);
|
|
||||||
|
|
||||||
thumbnails[size] = s3Key;
|
|
||||||
}
|
|
||||||
|
|
||||||
await editObject({
|
|
||||||
model: fileModel,
|
|
||||||
id: fileObject._id,
|
|
||||||
updateData: {
|
|
||||||
hasThumbnails: true,
|
|
||||||
metaData: {
|
|
||||||
...(fileObject.metaData || {}),
|
|
||||||
blurHash,
|
|
||||||
thumbnails,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error creating file thumbnails:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reusable function to delete an object by ID, with audit logging and distribution
|
// Reusable function to delete an object by ID, with audit logging and distribution
|
||||||
export const deleteObject = async (
|
export const deleteObject = async ({ model, id, user = null }, distributeChanges = true) => {
|
||||||
{ model, id, user = null, checkUnused = false },
|
|
||||||
distributeChanges = true
|
|
||||||
) => {
|
|
||||||
try {
|
try {
|
||||||
const parentType = model.modelName ? model.modelName : 'unknown';
|
const parentType = model.modelName ? model.modelName : 'unknown';
|
||||||
|
|
||||||
if (checkUnused) {
|
|
||||||
const dependencies = await listObjectDependencies({ model, id });
|
|
||||||
if (dependencies?.error) {
|
|
||||||
return { error: dependencies.error, code: dependencies.code || 500 };
|
|
||||||
}
|
|
||||||
if (dependencies?.length > 0) {
|
|
||||||
return {
|
|
||||||
error: 'Object is in use and cannot be deleted',
|
|
||||||
code: 409,
|
|
||||||
dependencies: dependencies.length,
|
|
||||||
dependencyDetails: dependencies,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete the object
|
// Delete the object
|
||||||
const result = await model.findByIdAndDelete(id);
|
const result = await model.findByIdAndDelete(id);
|
||||||
|
|
||||||
@ -1380,17 +883,8 @@ export const deleteObject = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const deleted = expandObjectIds(result.toObject());
|
const deleted = expandObjectIds(result.toObject());
|
||||||
|
|
||||||
// Audit log the deletion
|
// Audit log the deletion
|
||||||
await deleteAuditLog(deleted, id.toString(), parentType, user);
|
await deleteAuditLog(deleted, id, parentType, user, 'delete');
|
||||||
|
|
||||||
if (
|
|
||||||
parentType !== 'notification' &&
|
|
||||||
parentType !== 'auditLog' &&
|
|
||||||
parentType !== 'userNotifier'
|
|
||||||
) {
|
|
||||||
await deleteNotification(deleted, id.toString(), parentType, user);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (distributeChanges == true) {
|
if (distributeChanges == true) {
|
||||||
await distributeDelete(deleted, parentType);
|
await distributeDelete(deleted, parentType);
|
||||||
@ -1400,7 +894,6 @@ export const deleteObject = async (
|
|||||||
|
|
||||||
// Invalidate cache for this object
|
// Invalidate cache for this object
|
||||||
await deleteObjectCache({ model, id });
|
await deleteObjectCache({ model, id });
|
||||||
await invalidateNeighborsCacheForObject({ model, id });
|
|
||||||
|
|
||||||
if (model.recalculate) {
|
if (model.recalculate) {
|
||||||
logger.debug(`Recalculating ${model.modelName}`);
|
logger.debug(`Recalculating ${model.modelName}`);
|
||||||
@ -1474,6 +967,8 @@ export const flushFile = async ({ id, user }) => {
|
|||||||
// Try to delete from Ceph storage if it exists
|
// Try to delete from Ceph storage if it exists
|
||||||
if (file.extension) {
|
if (file.extension) {
|
||||||
try {
|
try {
|
||||||
|
const { deleteFile } = await import('./ceph.js');
|
||||||
|
const { BUCKETS } = await import('./ceph.js');
|
||||||
const cephKey = `files/${file._id}${file.extension}`;
|
const cephKey = `files/${file._id}${file.extension}`;
|
||||||
|
|
||||||
await deleteFile(BUCKETS.FILES, cephKey);
|
await deleteFile(BUCKETS.FILES, cephKey);
|
||||||
@ -1484,8 +979,6 @@ export const flushFile = async ({ id, user }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await deleteFileThumbnails(file);
|
|
||||||
|
|
||||||
const result = {
|
const result = {
|
||||||
success: true,
|
success: true,
|
||||||
deletedFile: {
|
deletedFile: {
|
||||||
|
|||||||
@ -1,160 +0,0 @@
|
|||||||
import { customAlphabet } from 'nanoid';
|
|
||||||
import log4js from 'log4js';
|
|
||||||
import ExcelJS from 'exceljs';
|
|
||||||
import config from '../config.js';
|
|
||||||
import { redisServer } from './redis.js';
|
|
||||||
|
|
||||||
const logger = log4js.getLogger('Excel');
|
|
||||||
const EXCEL_TEMP_KEY_PREFIX = 'excel:temp:';
|
|
||||||
const EXCEL_TEMP_TTL_SECONDS = 15; // 15 seconds
|
|
||||||
|
|
||||||
const excelNanoid = customAlphabet(
|
|
||||||
'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789',
|
|
||||||
12
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a temp token and store export params in Redis.
|
|
||||||
* @param {Object} params - { objectType, filter, sort, order }
|
|
||||||
* @returns {Promise<{ token: string, url: string }>}
|
|
||||||
*/
|
|
||||||
export async function createExcelTempToken(params) {
|
|
||||||
const baseUrl = config.app?.urlApi?.replace(/\/$/, '') || '';
|
|
||||||
if (!baseUrl) {
|
|
||||||
throw new Error('config.app.urlApi is not set; required for Excel temp URLs');
|
|
||||||
}
|
|
||||||
const objectType = params.objectType || 'Export';
|
|
||||||
const now = new Date();
|
|
||||||
const datetime =
|
|
||||||
now.getFullYear() +
|
|
||||||
String(now.getMonth() + 1).padStart(2, '0') +
|
|
||||||
String(now.getDate()).padStart(2, '0') +
|
|
||||||
String(now.getHours()).padStart(2, '0') +
|
|
||||||
String(now.getMinutes()).padStart(2, '0');
|
|
||||||
const token = `${objectType}s-${datetime}-${excelNanoid()}`;
|
|
||||||
const key = EXCEL_TEMP_KEY_PREFIX + token;
|
|
||||||
const stored = { ...params, requestCount: 0 };
|
|
||||||
await redisServer.setKey(key, stored, EXCEL_TEMP_TTL_SECONDS);
|
|
||||||
logger.debug('Stored excel temp token in Redis:', key);
|
|
||||||
const url = `${baseUrl}/excel/temp/${token}.xlsx`;
|
|
||||||
return { token, url };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get export params for a temp token (supports up to 2 requests; requestCount stored in Redis).
|
|
||||||
* @param {string} token
|
|
||||||
* @returns {Promise<Object|null>} { objectType, filter, sort, order } or null
|
|
||||||
*/
|
|
||||||
export async function getExcelTempParams(token) {
|
|
||||||
if (!token) return null;
|
|
||||||
const key = EXCEL_TEMP_KEY_PREFIX + token;
|
|
||||||
const params = await redisServer.getKey(key);
|
|
||||||
if (!params) {
|
|
||||||
logger.debug('Excel temp token not found in Redis:', key);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return params;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert a value to an Excel cell-friendly format.
|
|
||||||
* Primitives pass through; objects/arrays are stringified; dates are preserved.
|
|
||||||
*/
|
|
||||||
function toExcelValue(val) {
|
|
||||||
if (val === null || val === undefined) return null;
|
|
||||||
if (val instanceof Date) return val;
|
|
||||||
if (typeof val === 'number' || typeof val === 'boolean') return val;
|
|
||||||
if (typeof val === 'string') return val;
|
|
||||||
if (typeof val === 'object') {
|
|
||||||
if (Array.isArray(val)) return val.map(toExcelValue).join(', ');
|
|
||||||
return JSON.stringify(val);
|
|
||||||
}
|
|
||||||
return String(val);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate an Excel workbook from tabular data.
|
|
||||||
* @param {Array<Object>} data - Array of row objects (keys = column headers)
|
|
||||||
* @param {Object} options - Options
|
|
||||||
* @param {string} [options.sheetName='Export'] - Worksheet name
|
|
||||||
* @param {string[]} [options.columnOrder] - Optional column order (uses Object.keys of first row if not provided)
|
|
||||||
* @returns {Promise<Buffer>} Excel file as buffer
|
|
||||||
*/
|
|
||||||
export async function generateExcelTable(data, options = {}) {
|
|
||||||
const { sheetName = 'Export', columnOrder } = options;
|
|
||||||
|
|
||||||
const workbook = new ExcelJS.Workbook();
|
|
||||||
const worksheet = workbook.addWorksheet(sheetName, {
|
|
||||||
views: [{ state: 'frozen', ySplit: 1 }],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!data || data.length === 0) {
|
|
||||||
const buffer = await workbook.xlsx.writeBuffer();
|
|
||||||
return Buffer.from(buffer);
|
|
||||||
}
|
|
||||||
|
|
||||||
const keys = columnOrder || Object.keys(data[0]).filter((k) => !k.startsWith('@'));
|
|
||||||
const colCount = keys.length;
|
|
||||||
const rowCount = data.length + 1;
|
|
||||||
|
|
||||||
const toColLetter = (n) => {
|
|
||||||
let s = '';
|
|
||||||
while (n >= 0) {
|
|
||||||
s = String.fromCharCode((n % 26) + 65) + s;
|
|
||||||
n = Math.floor(n / 26) - 1;
|
|
||||||
}
|
|
||||||
return s;
|
|
||||||
};
|
|
||||||
const endCol = toColLetter(colCount - 1);
|
|
||||||
|
|
||||||
const tableRows = data.map((row) => keys.map((key) => toExcelValue(row[key])));
|
|
||||||
worksheet.addTable({
|
|
||||||
name: 'DataTable',
|
|
||||||
ref: `A1:${endCol}${rowCount}`,
|
|
||||||
headerRow: true,
|
|
||||||
style: {
|
|
||||||
theme: 'TableStyleLight1',
|
|
||||||
showRowStripes: true,
|
|
||||||
},
|
|
||||||
columns: keys.map((key) => ({ name: key, filterButton: true })),
|
|
||||||
rows: tableRows,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Auto-fit columns (approximate)
|
|
||||||
worksheet.columns.forEach((col, i) => {
|
|
||||||
let maxLen = keys[i]?.length || 10;
|
|
||||||
worksheet.eachRow({ includeEmpty: false }, (row) => {
|
|
||||||
const cell = row.getCell(i + 1);
|
|
||||||
const val = cell.value;
|
|
||||||
const len = val != null ? String(val).length : 0;
|
|
||||||
maxLen = Math.min(Math.max(maxLen, len), 50);
|
|
||||||
});
|
|
||||||
col.width = maxLen + 2;
|
|
||||||
});
|
|
||||||
|
|
||||||
const buffer = await workbook.xlsx.writeBuffer();
|
|
||||||
return Buffer.from(buffer);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Increment request count for a temp token. Returns new count or null if token not found.
|
|
||||||
* @param {string} token
|
|
||||||
* @returns {Promise<number|null>} New requestCount or null
|
|
||||||
*/
|
|
||||||
export async function incrementExcelTempRequestCount(token) {
|
|
||||||
if (!token) return null;
|
|
||||||
const key = EXCEL_TEMP_KEY_PREFIX + token;
|
|
||||||
const data = await redisServer.getKey(key);
|
|
||||||
if (!data) return null;
|
|
||||||
const requestCount = (data.requestCount ?? 0) + 1;
|
|
||||||
const updated = { ...data, requestCount };
|
|
||||||
await redisServer.setKey(key, updated, EXCEL_TEMP_TTL_SECONDS);
|
|
||||||
logger.debug('Incremented excel temp request count:', key, requestCount);
|
|
||||||
return requestCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteExcelTempToken(token) {
|
|
||||||
const key = EXCEL_TEMP_KEY_PREFIX + token;
|
|
||||||
await redisServer.deleteKey(key);
|
|
||||||
logger.debug('Deleted excel temp token from Redis:', key);
|
|
||||||
}
|
|
||||||
@ -1,164 +0,0 @@
|
|||||||
import log4js from 'log4js';
|
|
||||||
import mongoose from 'mongoose';
|
|
||||||
import config from '../config.js';
|
|
||||||
import { listObjects } from './database.js';
|
|
||||||
|
|
||||||
const logger = log4js.getLogger('DatabaseOData');
|
|
||||||
logger.level = config.server.logLevel;
|
|
||||||
|
|
||||||
const EXCLUDED_PATHS = ['appPasswordHash', 'secret', '__v'];
|
|
||||||
|
|
||||||
/** Check if path is an embedded object (has nested schema, not ObjectId ref). Excludes arrays. */
|
|
||||||
function isEmbeddedObject(path) {
|
|
||||||
if (!path) return false;
|
|
||||||
if (path.options?.type === mongoose.Schema.Types.ObjectId) return false;
|
|
||||||
if (path.instance === 'Array') return false;
|
|
||||||
return path.schema != null || path.caster?.schema != null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get all top-level property keys declared in OData metadata for a schema. */
|
|
||||||
function getDeclaredPropertyKeys(schema) {
|
|
||||||
if (!schema?.paths) return [];
|
|
||||||
return Object.keys(schema.paths)
|
|
||||||
.filter((k) => !k.includes('.'))
|
|
||||||
.filter((k) => !EXCLUDED_PATHS.includes(k))
|
|
||||||
.filter((k) => schema.paths[k]?.options?.select !== false);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensure all declared properties are present in each document. OData clients (e.g. Excel)
|
|
||||||
* expect every metadata property to exist; omit none. Use null for missing/invalid values.
|
|
||||||
*/
|
|
||||||
function normalizeODataResponse(data, schema) {
|
|
||||||
const declaredKeys = getDeclaredPropertyKeys(schema);
|
|
||||||
if (declaredKeys.length === 0) return data;
|
|
||||||
|
|
||||||
function normalize(doc) {
|
|
||||||
if (!doc || typeof doc !== 'object' || Array.isArray(doc)) return doc;
|
|
||||||
const result = { ...doc };
|
|
||||||
for (const key of declaredKeys) {
|
|
||||||
const val = result[key];
|
|
||||||
if (isEmbeddedObject(schema.paths[key])) {
|
|
||||||
// ComplexType: object or null only
|
|
||||||
const isValid = val != null && typeof val === 'object' && !Array.isArray(val);
|
|
||||||
result[key] = isValid ? val : null;
|
|
||||||
} else {
|
|
||||||
// Primitive, array, etc.: value or null (never omit)
|
|
||||||
result[key] = val !== undefined && val !== null ? val : null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.isArray(data) ? data.map(normalize) : normalize(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert expanded ObjectId refs ({ _id: x }) back to primitive strings for OData.
|
|
||||||
* OData metadata declares refs as Edm.String; clients expect primitives, not objects.
|
|
||||||
*/
|
|
||||||
function compactObjectIdsForOData(data) {
|
|
||||||
function isExpandedRef(val) {
|
|
||||||
return val && typeof val === 'object' && !Array.isArray(val) && Object.keys(val).length === 1 && '_id' in val;
|
|
||||||
}
|
|
||||||
function compact(val) {
|
|
||||||
if (Array.isArray(val)) return val.map(compact);
|
|
||||||
if (isExpandedRef(val)) return val._id?.toString?.() ?? val._id;
|
|
||||||
if (val instanceof Date || val instanceof Buffer) return val; // pass through primitives
|
|
||||||
if (val && typeof val === 'object') {
|
|
||||||
const result = {};
|
|
||||||
for (const [k, v] of Object.entries(val)) result[k] = compact(v);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
return val;
|
|
||||||
}
|
|
||||||
return Array.isArray(data) ? data.map(compact) : compact(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Translate filter keys ending with ._id for Mongoose compatibility
|
|
||||||
* @param {Object} filter - Filter object (will be mutated)
|
|
||||||
*/
|
|
||||||
function translateFilterKeys(filter) {
|
|
||||||
Object.keys(filter).forEach((key) => {
|
|
||||||
if (key.endsWith('._id')) {
|
|
||||||
const baseKey = key.slice(0, -4);
|
|
||||||
filter[baseKey] = filter[key];
|
|
||||||
delete filter[key];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* List objects with OData-compatible response format.
|
|
||||||
* Works like listObjects but returns { @odata.context, @odata.count?, value }.
|
|
||||||
*
|
|
||||||
* @param {Object} options - Same options as listObjects, plus:
|
|
||||||
* @param {boolean} [options.count=false] - When true, include @odata.count (total before $skip/$top)
|
|
||||||
* @returns {Promise<Object>} OData-formatted response or { error, code } on failure
|
|
||||||
*/
|
|
||||||
export const listObjectsOData = async ({
|
|
||||||
model,
|
|
||||||
populate = [],
|
|
||||||
page = 1,
|
|
||||||
limit = 25,
|
|
||||||
filter = {},
|
|
||||||
sort = '',
|
|
||||||
order = 'ascend',
|
|
||||||
pagination = true,
|
|
||||||
project,
|
|
||||||
count = false,
|
|
||||||
}) => {
|
|
||||||
try {
|
|
||||||
const filterCopy = JSON.parse(JSON.stringify(filter));
|
|
||||||
translateFilterKeys(filterCopy);
|
|
||||||
|
|
||||||
const [data, total] = await Promise.all([
|
|
||||||
listObjects({
|
|
||||||
model,
|
|
||||||
populate,
|
|
||||||
page,
|
|
||||||
limit,
|
|
||||||
filter: filterCopy,
|
|
||||||
sort,
|
|
||||||
order,
|
|
||||||
pagination,
|
|
||||||
project,
|
|
||||||
}),
|
|
||||||
count ? model.countDocuments(filterCopy) : Promise.resolve(undefined),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (data?.error) {
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Strip __v from each document (Mongoose version key - causes OData client issues)
|
|
||||||
const sanitizedData = Array.isArray(data)
|
|
||||||
? data.map(({ __v, ...doc }) => doc)
|
|
||||||
: data;
|
|
||||||
|
|
||||||
// Convert expanded ObjectId refs ({ _id: x }) to primitive strings - OData clients expect Edm.String
|
|
||||||
let odataData = compactObjectIdsForOData(sanitizedData);
|
|
||||||
|
|
||||||
// Ensure all declared properties are present; use null for missing values (Excel requires this)
|
|
||||||
odataData = normalizeODataResponse(odataData, model.schema);
|
|
||||||
|
|
||||||
const baseUrl = config.app?.urlApi || '';
|
|
||||||
const modelName = model.modelName;
|
|
||||||
const context = `${baseUrl}/odata/$metadata#${modelName}`;
|
|
||||||
|
|
||||||
const response = {
|
|
||||||
'@odata.context': context,
|
|
||||||
value: odataData,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (count && total !== undefined) {
|
|
||||||
response['@odata.count'] = total;
|
|
||||||
}
|
|
||||||
|
|
||||||
return response;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('OData list error:', error);
|
|
||||||
return { error, code: 500 };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import Redis from 'ioredis';
|
import { createClient } from 'redis';
|
||||||
import log4js from 'log4js';
|
import log4js from 'log4js';
|
||||||
import config from '../config.js';
|
import config from '../config.js';
|
||||||
|
|
||||||
@ -7,14 +7,11 @@ logger.level = config.server.logLevel;
|
|||||||
|
|
||||||
class RedisServer {
|
class RedisServer {
|
||||||
constructor() {
|
constructor() {
|
||||||
const url =
|
const url = config.database.redis.url || `redis://${config.database.redis.host}:${config.database.redis.port}`;
|
||||||
config.database.redis.url ||
|
|
||||||
`redis://${config.database.redis.host}:${config.database.redis.port}`;
|
|
||||||
const password = config.database.redis.password || undefined;
|
|
||||||
|
|
||||||
this.client = new Redis(url, {
|
this.client = createClient({
|
||||||
password,
|
url,
|
||||||
lazyConnect: true,
|
password: config.database.redis.password || undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.client.on('error', (err) => {
|
this.client.on('error', (err) => {
|
||||||
@ -36,7 +33,7 @@ class RedisServer {
|
|||||||
await this.connect();
|
await this.connect();
|
||||||
const payload = typeof value === 'string' ? value : JSON.stringify(value);
|
const payload = typeof value === 'string' ? value : JSON.stringify(value);
|
||||||
if (ttlSeconds) {
|
if (ttlSeconds) {
|
||||||
await this.client.set(key, payload, 'EX', ttlSeconds);
|
await this.client.set(key, payload, { EX: ttlSeconds });
|
||||||
} else {
|
} else {
|
||||||
await this.client.set(key, payload);
|
await this.client.set(key, payload);
|
||||||
}
|
}
|
||||||
@ -58,41 +55,17 @@ class RedisServer {
|
|||||||
await this.client.del(key);
|
await this.client.del(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAndDeleteKey(key) {
|
|
||||||
await this.connect();
|
|
||||||
const value = await this.client.getdel(key);
|
|
||||||
if (value == null) return null;
|
|
||||||
try {
|
|
||||||
return JSON.parse(value);
|
|
||||||
} catch {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async eval(script, keys = [], args = []) {
|
|
||||||
await this.connect();
|
|
||||||
return this.client.eval(
|
|
||||||
script,
|
|
||||||
keys.length,
|
|
||||||
...keys,
|
|
||||||
...args.map((arg) => String(arg))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getKeysByPattern(pattern) {
|
async getKeysByPattern(pattern) {
|
||||||
await this.connect();
|
await this.connect();
|
||||||
const keys = [];
|
const keys = [];
|
||||||
let cursor = '0';
|
let cursor = '0';
|
||||||
do {
|
do {
|
||||||
const [nextCursor, foundKeys] = await this.client.scan(
|
const result = await this.client.scan(cursor, {
|
||||||
cursor,
|
MATCH: pattern,
|
||||||
'MATCH',
|
COUNT: 100,
|
||||||
pattern,
|
});
|
||||||
'COUNT',
|
cursor = result.cursor;
|
||||||
100
|
keys.push(...result.keys);
|
||||||
);
|
|
||||||
cursor = nextCursor;
|
|
||||||
keys.push(...foundKeys);
|
|
||||||
} while (cursor !== '0');
|
} while (cursor !== '0');
|
||||||
return keys;
|
return keys;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,191 +0,0 @@
|
|||||||
import mongoose from 'mongoose';
|
|
||||||
import { generateId } from '../../utils.js';
|
|
||||||
const { Schema } = mongoose;
|
|
||||||
import { aggregateRollups, aggregateRollupsHistory, editObject } from '../../database.js';
|
|
||||||
|
|
||||||
const invoiceOrderItemSchema = new Schema(
|
|
||||||
{
|
|
||||||
orderItem: { type: Schema.Types.ObjectId, ref: 'orderItem', required: true },
|
|
||||||
taxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
|
|
||||||
invoiceAmountWithTax: { type: Number, required: true, default: 0 },
|
|
||||||
invoiceAmount: { type: Number, required: true, default: 0 },
|
|
||||||
invoiceQuantity: { type: Number, required: true, default: 0 },
|
|
||||||
},
|
|
||||||
{ timestamps: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
const invoiceShipmentSchema = new Schema(
|
|
||||||
{
|
|
||||||
shipment: { type: Schema.Types.ObjectId, ref: 'shipment', required: true },
|
|
||||||
taxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
|
|
||||||
invoiceAmountWithTax: { type: Number, required: true, default: 0 },
|
|
||||||
invoiceAmount: { type: Number, required: true, default: 0 },
|
|
||||||
},
|
|
||||||
{ timestamps: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
const invoiceSchema = new Schema(
|
|
||||||
{
|
|
||||||
_reference: { type: String, default: () => generateId()() },
|
|
||||||
totalAmount: { type: Number, required: true, default: 0 },
|
|
||||||
totalAmountWithTax: { type: Number, required: true, default: 0 },
|
|
||||||
shippingAmount: { type: Number, required: true, default: 0 },
|
|
||||||
shippingAmountWithTax: { type: Number, required: true, default: 0 },
|
|
||||||
grandTotalAmount: { type: Number, required: true, default: 0 },
|
|
||||||
totalTaxAmount: { type: Number, required: true, default: 0 },
|
|
||||||
from: { type: Schema.Types.ObjectId, refPath: 'fromType', required: false },
|
|
||||||
fromType: { type: String, required: false },
|
|
||||||
to: { type: Schema.Types.ObjectId, refPath: 'toType', required: false },
|
|
||||||
toType: { type: String, required: false },
|
|
||||||
state: {
|
|
||||||
type: { type: String, required: true, default: 'draft' },
|
|
||||||
},
|
|
||||||
orderType: { type: String, required: true },
|
|
||||||
order: { type: Schema.Types.ObjectId, refPath: 'orderType', required: true },
|
|
||||||
issuedAt: { type: Date, required: false },
|
|
||||||
dueAt: { type: Date, required: false },
|
|
||||||
postedAt: { type: Date, required: false },
|
|
||||||
acknowledgedAt: { type: Date, required: false },
|
|
||||||
paidAt: { type: Date, required: false },
|
|
||||||
cancelledAt: { type: Date, required: false },
|
|
||||||
invoiceOrderItems: [invoiceOrderItemSchema],
|
|
||||||
invoiceShipments: [invoiceShipmentSchema],
|
|
||||||
},
|
|
||||||
{ timestamps: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
invoiceSchema.index({ orderType: 'text', fromType: 'text', toType: 'text' });
|
|
||||||
|
|
||||||
const rollupConfigs = [
|
|
||||||
{
|
|
||||||
name: 'draft',
|
|
||||||
filter: { 'state.type': 'draft' },
|
|
||||||
rollups: [
|
|
||||||
{ name: 'draftCount', property: 'state.type', operation: 'count' },
|
|
||||||
{ name: 'draftGrandTotalAmount', property: 'grandTotalAmount', operation: 'sum' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'sent',
|
|
||||||
filter: { 'state.type': 'sent' },
|
|
||||||
rollups: [
|
|
||||||
{ name: 'sentCount', property: 'state.type', operation: 'count' },
|
|
||||||
{ name: 'sentGrandTotalAmount', property: 'grandTotalAmount', operation: 'sum' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'acknowledged',
|
|
||||||
filter: { 'state.type': 'acknowledged' },
|
|
||||||
rollups: [
|
|
||||||
{ name: 'acknowledgedCount', property: 'state.type', operation: 'count' },
|
|
||||||
{ name: 'acknowledgedGrandTotalAmount', property: 'grandTotalAmount', operation: 'sum' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'partiallyPaid',
|
|
||||||
filter: { 'state.type': 'partiallyPaid' },
|
|
||||||
rollups: [
|
|
||||||
{ name: 'partiallyPaidCount', property: 'state.type', operation: 'count' },
|
|
||||||
{ name: 'partiallyPaidGrandTotalAmount', property: 'grandTotalAmount', operation: 'sum' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'paid',
|
|
||||||
filter: { 'state.type': 'paid' },
|
|
||||||
rollups: [{ name: 'paidCount', property: 'state.type', operation: 'count' }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'overdue',
|
|
||||||
filter: { 'state.type': 'overdue' },
|
|
||||||
rollups: [{ name: 'overdueCount', property: 'state.type', operation: 'count' }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'cancelled',
|
|
||||||
filter: { 'state.type': 'cancelled' },
|
|
||||||
rollups: [{ name: 'cancelledCount', property: 'state.type', operation: 'count' }],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
invoiceSchema.statics.stats = async function () {
|
|
||||||
const results = await aggregateRollups({
|
|
||||||
model: this,
|
|
||||||
rollupConfigs: rollupConfigs,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Transform the results to match the expected format
|
|
||||||
return results;
|
|
||||||
};
|
|
||||||
|
|
||||||
invoiceSchema.statics.history = async function (from, to) {
|
|
||||||
const results = await aggregateRollupsHistory({
|
|
||||||
model: this,
|
|
||||||
startDate: from,
|
|
||||||
endDate: to,
|
|
||||||
rollupConfigs: rollupConfigs,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Return time-series data array
|
|
||||||
return results;
|
|
||||||
};
|
|
||||||
|
|
||||||
invoiceSchema.statics.recalculate = async function (invoice, user) {
|
|
||||||
const invoiceId = invoice._id || invoice;
|
|
||||||
if (!invoiceId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate totals from invoiceOrderItems
|
|
||||||
let totalAmount = 0;
|
|
||||||
for (const item of invoice.invoiceOrderItems || []) {
|
|
||||||
totalAmount += Number.parseFloat(item.invoiceAmount) || 0;
|
|
||||||
}
|
|
||||||
let totalAmountWithTax = 0;
|
|
||||||
for (const item of invoice.invoiceOrderItems || []) {
|
|
||||||
totalAmountWithTax += Number.parseFloat(item.invoiceAmountWithTax) || 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate shipping totals from invoiceShipments
|
|
||||||
let shippingAmount = 0;
|
|
||||||
for (const item of invoice.invoiceShipments || []) {
|
|
||||||
shippingAmount += Number.parseFloat(item.invoiceAmount) || 0;
|
|
||||||
}
|
|
||||||
let shippingAmountWithTax = 0;
|
|
||||||
for (const item of invoice.invoiceShipments || []) {
|
|
||||||
shippingAmountWithTax += Number.parseFloat(item.invoiceAmountWithTax) || 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate grand total and tax amount
|
|
||||||
const grandTotalAmount = parseFloat(totalAmountWithTax) + parseFloat(shippingAmountWithTax);
|
|
||||||
const totalTaxAmount =
|
|
||||||
parseFloat(totalAmountWithTax) -
|
|
||||||
parseFloat(totalAmount) +
|
|
||||||
(parseFloat(shippingAmountWithTax) - parseFloat(shippingAmount));
|
|
||||||
|
|
||||||
const updateData = {
|
|
||||||
totalAmount: parseFloat(totalAmount).toFixed(2),
|
|
||||||
totalAmountWithTax: parseFloat(totalAmountWithTax).toFixed(2),
|
|
||||||
shippingAmount: parseFloat(shippingAmount).toFixed(2),
|
|
||||||
shippingAmountWithTax: parseFloat(shippingAmountWithTax).toFixed(2),
|
|
||||||
grandTotalAmount: parseFloat(grandTotalAmount).toFixed(2),
|
|
||||||
totalTaxAmount: parseFloat(totalTaxAmount).toFixed(2),
|
|
||||||
};
|
|
||||||
|
|
||||||
await editObject({
|
|
||||||
model: this,
|
|
||||||
id: invoiceId,
|
|
||||||
updateData,
|
|
||||||
user,
|
|
||||||
recalculate: false,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add virtual id getter
|
|
||||||
invoiceSchema.virtual('id').get(function () {
|
|
||||||
return this._id;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Configure JSON serialization to include virtuals
|
|
||||||
invoiceSchema.set('toJSON', { virtuals: true });
|
|
||||||
|
|
||||||
// Create and export the model
|
|
||||||
export const invoiceModel = mongoose.model('invoice', invoiceSchema);
|
|
||||||
@ -1,124 +0,0 @@
|
|||||||
import mongoose from 'mongoose';
|
|
||||||
import { generateId } from '../../utils.js';
|
|
||||||
const { Schema } = mongoose;
|
|
||||||
import { aggregateRollups, aggregateRollupsHistory, editObject } from '../../database.js';
|
|
||||||
|
|
||||||
const paymentSchema = new Schema(
|
|
||||||
{
|
|
||||||
_reference: { type: String, default: () => generateId()() },
|
|
||||||
amount: { type: Number, required: true, default: 0 },
|
|
||||||
vendor: { type: Schema.Types.ObjectId, ref: 'vendor', required: false },
|
|
||||||
client: { type: Schema.Types.ObjectId, ref: 'client', required: false },
|
|
||||||
invoice: { type: Schema.Types.ObjectId, ref: 'invoice', required: true },
|
|
||||||
state: {
|
|
||||||
type: { type: String, required: true, default: 'draft' },
|
|
||||||
},
|
|
||||||
postedAt: { type: Date, required: false },
|
|
||||||
authorisedAt: { type: Date, required: false },
|
|
||||||
declinedAt: { type: Date, required: false },
|
|
||||||
cancelledAt: { type: Date, required: false },
|
|
||||||
paymentMethod: { type: String, required: false },
|
|
||||||
notes: { type: String, required: false },
|
|
||||||
},
|
|
||||||
{ timestamps: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
paymentSchema.index({ paymentMethod: 'text', notes: 'text' });
|
|
||||||
|
|
||||||
const rollupConfigs = [
|
|
||||||
{
|
|
||||||
name: 'draft',
|
|
||||||
filter: { 'state.type': 'draft' },
|
|
||||||
rollups: [
|
|
||||||
{ name: 'draftCount', property: 'state.type', operation: 'count' },
|
|
||||||
{ name: 'draftAmount', property: 'amount', operation: 'sum' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'posted',
|
|
||||||
filter: { 'state.type': 'posted' },
|
|
||||||
rollups: [
|
|
||||||
{ name: 'postedCount', property: 'state.type', operation: 'count' },
|
|
||||||
{ name: 'postedAmount', property: 'amount', operation: 'sum' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'authorised',
|
|
||||||
filter: { 'state.type': 'authorised' },
|
|
||||||
rollups: [
|
|
||||||
{ name: 'authorisedCount', property: 'state.type', operation: 'count' },
|
|
||||||
{ name: 'authorisedAmount', property: 'amount', operation: 'sum' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'declined',
|
|
||||||
filter: { 'state.type': 'declined' },
|
|
||||||
rollups: [
|
|
||||||
{ name: 'declinedCount', property: 'state.type', operation: 'count' },
|
|
||||||
{ name: 'declinedAmount', property: 'amount', operation: 'sum' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'cancelled',
|
|
||||||
filter: { 'state.type': 'cancelled' },
|
|
||||||
rollups: [
|
|
||||||
{ name: 'cancelledCount', property: 'state.type', operation: 'count' },
|
|
||||||
{ name: 'cancelledAmount', property: 'amount', operation: 'sum' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
paymentSchema.statics.stats = async function () {
|
|
||||||
const results = await aggregateRollups({
|
|
||||||
model: this,
|
|
||||||
rollupConfigs: rollupConfigs,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Transform the results to match the expected format
|
|
||||||
return results;
|
|
||||||
};
|
|
||||||
|
|
||||||
paymentSchema.statics.history = async function (from, to) {
|
|
||||||
const results = await aggregateRollupsHistory({
|
|
||||||
model: this,
|
|
||||||
startDate: from,
|
|
||||||
endDate: to,
|
|
||||||
rollupConfigs: rollupConfigs,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Return time-series data array
|
|
||||||
return results;
|
|
||||||
};
|
|
||||||
|
|
||||||
paymentSchema.statics.recalculate = async function (payment, user) {
|
|
||||||
const paymentId = payment._id || payment;
|
|
||||||
if (!paymentId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For payments, the amount is set directly
|
|
||||||
const amount = payment.amount || 0;
|
|
||||||
|
|
||||||
const updateData = {
|
|
||||||
amount: parseFloat(amount).toFixed(2),
|
|
||||||
};
|
|
||||||
|
|
||||||
await editObject({
|
|
||||||
model: this,
|
|
||||||
id: paymentId,
|
|
||||||
updateData,
|
|
||||||
user,
|
|
||||||
recalculate: false,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add virtual id getter
|
|
||||||
paymentSchema.virtual('id').get(function () {
|
|
||||||
return this._id;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Configure JSON serialization to include virtuals
|
|
||||||
paymentSchema.set('toJSON', { virtuals: true });
|
|
||||||
|
|
||||||
// Create and export the model
|
|
||||||
export const paymentModel = mongoose.model('payment', paymentSchema);
|
|
||||||
@ -19,39 +19,11 @@ const filamentStockSchema = new Schema(
|
|||||||
net: { type: Number, required: true },
|
net: { type: Number, required: true },
|
||||||
gross: { type: Number, required: true },
|
gross: { type: Number, required: true },
|
||||||
},
|
},
|
||||||
history: [
|
|
||||||
{
|
|
||||||
currentWeight: {
|
|
||||||
net: { type: Number, required: true },
|
|
||||||
gross: { type: Number, required: true },
|
|
||||||
},
|
|
||||||
timestamp: { type: Date, default: Date.now },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
filament: { type: mongoose.Schema.Types.ObjectId, ref: 'filament', required: true },
|
filament: { type: mongoose.Schema.Types.ObjectId, ref: 'filament', required: true },
|
||||||
filamentSku: { type: mongoose.Schema.Types.ObjectId, ref: 'filamentSku', required: true },
|
|
||||||
stockLocation: {
|
|
||||||
type: mongoose.Schema.Types.ObjectId,
|
|
||||||
ref: 'stockLocation',
|
|
||||||
required: false,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{ timestamps: true }
|
{ timestamps: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
filamentStockSchema.index({ 'state.type': 'text' });
|
|
||||||
|
|
||||||
filamentStockSchema.pre('validate', async function () {
|
|
||||||
if (!this.filament && this.filamentSku) {
|
|
||||||
const sku = await mongoose
|
|
||||||
.model('filamentSku')
|
|
||||||
.findById(this.filamentSku)
|
|
||||||
.select('filament')
|
|
||||||
.lean();
|
|
||||||
if (sku?.filament) this.filament = sku.filament;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const rollupConfigs = [
|
const rollupConfigs = [
|
||||||
{
|
{
|
||||||
name: 'totalCurrentWeight',
|
name: 'totalCurrentWeight',
|
||||||
|
|||||||
@ -1,114 +1,30 @@
|
|||||||
import mongoose from 'mongoose';
|
import mongoose from 'mongoose';
|
||||||
import { purchaseOrderModel } from './purchaseorder.schema.js';
|
import { purchaseOrderModel } from './purchaseorder.schema.js';
|
||||||
import { salesOrderModel } from '../sales/salesorder.schema.js';
|
import { aggregateRollups, editObject } from '../../database.js';
|
||||||
import { taxRateModel } from '../management/taxrate.schema.js';
|
|
||||||
import { filamentModel } from '../management/filament.schema.js';
|
|
||||||
import { filamentSkuModel } from '../management/filamentsku.schema.js';
|
|
||||||
import { partModel } from '../management/part.schema.js';
|
|
||||||
import { partSkuModel } from '../management/partsku.schema.js';
|
|
||||||
import { productModel } from '../management/product.schema.js';
|
|
||||||
import { productSkuModel } from '../management/productsku.schema.js';
|
|
||||||
import {
|
|
||||||
aggregateRollups,
|
|
||||||
aggregateRollupsHistory,
|
|
||||||
editObject,
|
|
||||||
getObject,
|
|
||||||
} from '../../database.js';
|
|
||||||
import { generateId } from '../../utils.js';
|
import { generateId } from '../../utils.js';
|
||||||
const { Schema } = mongoose;
|
const { Schema } = mongoose;
|
||||||
|
|
||||||
const skuModelsByItemType = {
|
|
||||||
filament: filamentSkuModel,
|
|
||||||
part: partSkuModel,
|
|
||||||
product: productSkuModel,
|
|
||||||
};
|
|
||||||
|
|
||||||
const parentModelsByItemType = {
|
|
||||||
filament: filamentModel,
|
|
||||||
part: partModel,
|
|
||||||
product: productModel,
|
|
||||||
};
|
|
||||||
|
|
||||||
const orderItemSchema = new Schema(
|
const orderItemSchema = new Schema(
|
||||||
{
|
{
|
||||||
_reference: { type: String, default: () => generateId()() },
|
_reference: { type: String, default: () => generateId()() },
|
||||||
orderType: { type: String, required: true },
|
orderType: { type: String, required: true },
|
||||||
name: { type: String, required: true },
|
|
||||||
state: {
|
|
||||||
type: { type: String, required: true, default: 'draft' },
|
|
||||||
},
|
|
||||||
order: { type: Schema.Types.ObjectId, refPath: 'orderType', required: true },
|
order: { type: Schema.Types.ObjectId, refPath: 'orderType', required: true },
|
||||||
itemType: { type: String, required: true },
|
itemType: { type: String, required: true },
|
||||||
item: { type: Schema.Types.ObjectId, refPath: 'itemType', required: false },
|
item: { type: Schema.Types.ObjectId, refPath: 'itemType', required: true },
|
||||||
sku: {
|
syncAmount: { type: String, required: true, default: null },
|
||||||
type: Schema.Types.ObjectId,
|
|
||||||
ref: function () {
|
|
||||||
return ['filament', 'part', 'product'].includes(this.itemType)
|
|
||||||
? this.itemType + 'Sku'
|
|
||||||
: null;
|
|
||||||
},
|
|
||||||
required: false,
|
|
||||||
},
|
|
||||||
syncAmount: { type: String, required: false, default: null },
|
|
||||||
itemAmount: { type: Number, required: true },
|
itemAmount: { type: Number, required: true },
|
||||||
quantity: { type: Number, required: true },
|
quantity: { type: Number, required: true },
|
||||||
totalAmount: { type: Number, required: true },
|
totalAmount: { type: Number, required: true },
|
||||||
taxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
|
taxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
|
||||||
totalAmountWithTax: { type: Number, required: true },
|
totalAmountWithTax: { type: Number, required: true },
|
||||||
invoicedAmountWithTax: { type: Number, required: false, default: 0 },
|
|
||||||
invoicedAmount: { type: Number, required: false, default: 0 },
|
|
||||||
invoicedQuantity: { type: Number, required: false, default: 0 },
|
|
||||||
invoicedAmountRemaining: { type: Number, required: false, default: 0 },
|
|
||||||
invoicedAmountWithTaxRemaining: { type: Number, required: false, default: 0 },
|
|
||||||
invoicedQuantityRemaining: { type: Number, required: false, default: 0 },
|
|
||||||
timestamp: { type: Date, default: Date.now },
|
timestamp: { type: Date, default: Date.now },
|
||||||
shipment: { type: Schema.Types.ObjectId, ref: 'shipment', required: false },
|
|
||||||
orderedAt: { type: Date, required: false },
|
|
||||||
receivedAt: { type: Date, required: false },
|
|
||||||
},
|
},
|
||||||
{ timestamps: true }
|
{ timestamps: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
orderItemSchema.index({ name: 'text', itemType: 'text', orderType: 'text' });
|
|
||||||
|
|
||||||
const rollupConfigs = [
|
|
||||||
{
|
|
||||||
name: 'shipped',
|
|
||||||
filter: { 'state.type': 'shipped' },
|
|
||||||
rollups: [{ name: 'shipped', property: 'state.type', operation: 'count' }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'received',
|
|
||||||
filter: { 'state.type': 'received' },
|
|
||||||
rollups: [{ name: 'received', property: 'state.type', operation: 'count' }],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
orderItemSchema.statics.stats = async function () {
|
|
||||||
const results = await aggregateRollups({
|
|
||||||
model: this,
|
|
||||||
baseFilter: {},
|
|
||||||
rollupConfigs: rollupConfigs,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Transform the results to match the expected format
|
|
||||||
return results;
|
|
||||||
};
|
|
||||||
|
|
||||||
orderItemSchema.statics.history = async function (from, to) {
|
|
||||||
const results = await aggregateRollupsHistory({
|
|
||||||
model: this,
|
|
||||||
startDate: from,
|
|
||||||
endDate: to,
|
|
||||||
rollupConfigs: rollupConfigs,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Return time-series data array
|
|
||||||
return results;
|
|
||||||
};
|
|
||||||
|
|
||||||
orderItemSchema.statics.recalculate = async function (orderItem, user) {
|
orderItemSchema.statics.recalculate = async function (orderItem, user) {
|
||||||
if (orderItem.orderType !== 'purchaseOrder' && orderItem.orderType !== 'salesOrder') {
|
// Only purchase orders are supported for now
|
||||||
|
if (orderItem.orderType !== 'purchaseOrder') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -117,85 +33,6 @@ orderItemSchema.statics.recalculate = async function (orderItem, user) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If SKU present and syncAmount is set, check if override is on for the price mode and use that price instead
|
|
||||||
let effectiveItemAmount = orderItem.itemAmount;
|
|
||||||
const syncAmount = orderItem.syncAmount;
|
|
||||||
const skuId = orderItem.sku?._id || orderItem.sku;
|
|
||||||
const itemType = orderItem.itemType;
|
|
||||||
if (syncAmount && skuId && itemType && ['filament', 'part', 'product'].includes(itemType)) {
|
|
||||||
const skuModel = skuModelsByItemType[itemType];
|
|
||||||
const parentModel = parentModelsByItemType[itemType];
|
|
||||||
if (skuModel && parentModel) {
|
|
||||||
const sku = await getObject({
|
|
||||||
model: skuModel,
|
|
||||||
id: skuId,
|
|
||||||
cached: true,
|
|
||||||
});
|
|
||||||
if (sku) {
|
|
||||||
const parentId = sku.part?._id || sku.part || sku.product?._id || sku.product || sku.filament?._id || sku.filament;
|
|
||||||
if (syncAmount === 'itemCost') {
|
|
||||||
if (sku.overrideCost && sku.cost != null) {
|
|
||||||
effectiveItemAmount = sku.cost;
|
|
||||||
} else if (parentId) {
|
|
||||||
const parent = await getObject({
|
|
||||||
model: parentModel,
|
|
||||||
id: parentId,
|
|
||||||
cached: true,
|
|
||||||
});
|
|
||||||
if (parent && parent.cost != null) {
|
|
||||||
effectiveItemAmount = parent.cost;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (syncAmount === 'itemPrice' && itemType !== 'filament') {
|
|
||||||
if (sku.overridePrice && sku.price != null) {
|
|
||||||
effectiveItemAmount = sku.price;
|
|
||||||
} else if (parentId) {
|
|
||||||
const parent = await getObject({
|
|
||||||
model: parentModel,
|
|
||||||
id: parentId,
|
|
||||||
cached: true,
|
|
||||||
});
|
|
||||||
if (parent && parent.price != null) {
|
|
||||||
effectiveItemAmount = parent.price;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let taxRate = orderItem.taxRate;
|
|
||||||
if (orderItem.taxRate?._id && Object.keys(orderItem.taxRate).length === 1) {
|
|
||||||
taxRate = await getObject({
|
|
||||||
model: taxRateModel,
|
|
||||||
id: orderItem.taxRate._id,
|
|
||||||
cached: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const orderTotalAmount = effectiveItemAmount * orderItem.quantity;
|
|
||||||
const orderTotalAmountWithTax = orderTotalAmount * (1 + (taxRate?.rate || 0) / 100);
|
|
||||||
|
|
||||||
const orderItemUpdateData = {
|
|
||||||
totalAmount: orderTotalAmount,
|
|
||||||
totalAmountWithTax: orderTotalAmountWithTax,
|
|
||||||
invoicedAmountRemaining: orderTotalAmount - orderItem.invoicedAmount,
|
|
||||||
invoicedAmountWithTaxRemaining: orderTotalAmountWithTax - orderItem.invoicedAmountWithTax,
|
|
||||||
invoicedQuantityRemaining: orderItem.quantity - orderItem.invoicedQuantity,
|
|
||||||
};
|
|
||||||
if (effectiveItemAmount !== orderItem.itemAmount) {
|
|
||||||
orderItemUpdateData.itemAmount = effectiveItemAmount;
|
|
||||||
orderItem.itemAmount = effectiveItemAmount;
|
|
||||||
}
|
|
||||||
|
|
||||||
await editObject({
|
|
||||||
model: this,
|
|
||||||
id: orderItem._id,
|
|
||||||
updateData: orderItemUpdateData,
|
|
||||||
user,
|
|
||||||
recalculate: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const rollupResults = await aggregateRollups({
|
const rollupResults = await aggregateRollups({
|
||||||
model: this,
|
model: this,
|
||||||
baseFilter: {
|
baseFilter: {
|
||||||
@ -214,11 +51,6 @@ orderItemSchema.statics.recalculate = async function (orderItem, user) {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'overallCount',
|
|
||||||
rollups: [{ name: 'overallCount', property: '_id', operation: 'count' }],
|
|
||||||
},
|
|
||||||
...rollupConfigs,
|
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -226,59 +58,14 @@ orderItemSchema.statics.recalculate = async function (orderItem, user) {
|
|||||||
const totalAmount = totals.totalAmount.sum?.toFixed(2) || 0;
|
const totalAmount = totals.totalAmount.sum?.toFixed(2) || 0;
|
||||||
const totalAmountWithTax = totals.totalAmountWithTax.sum?.toFixed(2) || 0;
|
const totalAmountWithTax = totals.totalAmountWithTax.sum?.toFixed(2) || 0;
|
||||||
|
|
||||||
const orderModel = orderItem.orderType === 'purchaseOrder' ? purchaseOrderModel : salesOrderModel;
|
|
||||||
const order = await getObject({
|
|
||||||
model: orderModel,
|
|
||||||
id: orderId,
|
|
||||||
cached: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const grandTotalAmount =
|
|
||||||
parseFloat(totalAmountWithTax || 0) + parseFloat(order.shippingAmountWithTax || 0);
|
|
||||||
|
|
||||||
var updateData = {
|
|
||||||
totalAmount: parseFloat(totalAmount).toFixed(2),
|
|
||||||
totalAmountWithTax: parseFloat(totalAmountWithTax).toFixed(2),
|
|
||||||
totalTaxAmount: parseFloat((totalAmountWithTax - totalAmount).toFixed(2)),
|
|
||||||
grandTotalAmount: parseFloat(grandTotalAmount).toFixed(2),
|
|
||||||
};
|
|
||||||
|
|
||||||
const overallCount = rollupResults.overallCount.count || 0;
|
|
||||||
const shippedCount = rollupResults.shipped.count || 0;
|
|
||||||
const receivedCount = rollupResults.received.count || 0;
|
|
||||||
|
|
||||||
if (orderItem.orderType === 'purchaseOrder') {
|
|
||||||
if (shippedCount > 0 && shippedCount < overallCount) {
|
|
||||||
updateData = { ...updateData, state: { type: 'partiallyShipped' } };
|
|
||||||
}
|
|
||||||
if (shippedCount > 0 && shippedCount == overallCount) {
|
|
||||||
updateData = { ...updateData, state: { type: 'shipped' } };
|
|
||||||
}
|
|
||||||
if (receivedCount > 0 && receivedCount < overallCount) {
|
|
||||||
updateData = { ...updateData, state: { type: 'partiallyReceived' } };
|
|
||||||
}
|
|
||||||
if (receivedCount > 0 && receivedCount == overallCount) {
|
|
||||||
updateData = { ...updateData, state: { type: 'received' } };
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (shippedCount > 0 && shippedCount < overallCount) {
|
|
||||||
updateData = { ...updateData, state: { type: 'partiallyShipped' } };
|
|
||||||
}
|
|
||||||
if (shippedCount > 0 && shippedCount == overallCount) {
|
|
||||||
updateData = { ...updateData, state: { type: 'shipped' } };
|
|
||||||
}
|
|
||||||
if (receivedCount > 0 && receivedCount < overallCount) {
|
|
||||||
updateData = { ...updateData, state: { type: 'partiallyDelivered' } };
|
|
||||||
}
|
|
||||||
if (receivedCount > 0 && receivedCount == overallCount) {
|
|
||||||
updateData = { ...updateData, state: { type: 'delivered' } };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await editObject({
|
await editObject({
|
||||||
model: orderModel,
|
model: purchaseOrderModel,
|
||||||
id: orderId,
|
id: orderId,
|
||||||
updateData: updateData,
|
updateData: {
|
||||||
|
totalAmount: parseFloat(totalAmount),
|
||||||
|
totalAmountWithTax: parseFloat(totalAmountWithTax),
|
||||||
|
totalTaxAmount: parseFloat(totalAmountWithTax - totalAmount),
|
||||||
|
},
|
||||||
user,
|
user,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@ -11,27 +11,14 @@ const partStockSchema = new Schema(
|
|||||||
type: { type: String, required: true },
|
type: { type: String, required: true },
|
||||||
progress: { type: Number, required: false },
|
progress: { type: Number, required: false },
|
||||||
},
|
},
|
||||||
partSku: { type: mongoose.Schema.Types.ObjectId, ref: 'partSku', required: true },
|
part: { type: mongoose.Schema.Types.ObjectId, ref: 'part', required: true },
|
||||||
stockLocation: {
|
|
||||||
type: mongoose.Schema.Types.ObjectId,
|
|
||||||
ref: 'stockLocation',
|
|
||||||
required: false,
|
|
||||||
},
|
|
||||||
currentQuantity: { type: Number, required: true },
|
currentQuantity: { type: Number, required: true },
|
||||||
history: [
|
|
||||||
{
|
|
||||||
currentQuantity: { type: Number, required: true },
|
|
||||||
timestamp: { type: Date, default: Date.now },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
sourceType: { type: String, required: true },
|
sourceType: { type: String, required: true },
|
||||||
source: { type: Schema.Types.ObjectId, refPath: 'sourceType', required: true },
|
source: { type: Schema.Types.ObjectId, refPath: 'sourceType', required: true },
|
||||||
},
|
},
|
||||||
{ timestamps: true }
|
{ timestamps: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
partStockSchema.index({ sourceType: 'text', 'state.type': 'text' });
|
|
||||||
|
|
||||||
const rollupConfigs = [
|
const rollupConfigs = [
|
||||||
{
|
{
|
||||||
name: 'totalCurrentQuantity',
|
name: 'totalCurrentQuantity',
|
||||||
|
|||||||
@ -1,88 +0,0 @@
|
|||||||
import mongoose from 'mongoose';
|
|
||||||
import { generateId } from '../../utils.js';
|
|
||||||
const { Schema } = mongoose;
|
|
||||||
import { aggregateRollups, aggregateRollupsHistory } from '../../database.js';
|
|
||||||
|
|
||||||
const partStockUsageSchema = new Schema({
|
|
||||||
partStock: { type: Schema.Types.ObjectId, ref: 'partStock', required: false },
|
|
||||||
partSku: { type: Schema.Types.ObjectId, ref: 'partSku', required: true },
|
|
||||||
quantity: { type: Number, required: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Define the main productStock schema - tracks assembled products consisting of part stocks
|
|
||||||
const productStockSchema = new Schema(
|
|
||||||
{
|
|
||||||
_reference: { type: String, default: () => generateId()() },
|
|
||||||
state: {
|
|
||||||
type: { type: String, required: true, default: 'draft' },
|
|
||||||
progress: { type: Number, required: false },
|
|
||||||
},
|
|
||||||
postedAt: { type: Date, required: false },
|
|
||||||
productSku: { type: mongoose.Schema.Types.ObjectId, ref: 'productSku', required: true },
|
|
||||||
stockLocation: {
|
|
||||||
type: mongoose.Schema.Types.ObjectId,
|
|
||||||
ref: 'stockLocation',
|
|
||||||
required: false,
|
|
||||||
},
|
|
||||||
currentQuantity: { type: Number, required: true },
|
|
||||||
history: [
|
|
||||||
{
|
|
||||||
currentQuantity: { type: Number, required: true },
|
|
||||||
timestamp: { type: Date, default: Date.now },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
partStocks: [partStockUsageSchema],
|
|
||||||
},
|
|
||||||
{ timestamps: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
productStockSchema.index({ 'state.type': 'text' });
|
|
||||||
|
|
||||||
const rollupConfigs = [
|
|
||||||
{
|
|
||||||
name: 'totalCurrentQuantity',
|
|
||||||
filter: {},
|
|
||||||
rollups: [{ name: 'totalCurrentQuantity', property: 'currentQuantity', operation: 'sum' }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'draft',
|
|
||||||
filter: { 'state.type': 'draft' },
|
|
||||||
rollups: [{ name: 'draft', property: 'state.type', operation: 'count' }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'posted',
|
|
||||||
filter: { 'state.type': 'posted' },
|
|
||||||
rollups: [{ name: 'posted', property: 'state.type', operation: 'count' }],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
productStockSchema.statics.stats = async function () {
|
|
||||||
const results = await aggregateRollups({
|
|
||||||
model: this,
|
|
||||||
rollupConfigs: rollupConfigs,
|
|
||||||
});
|
|
||||||
|
|
||||||
return results;
|
|
||||||
};
|
|
||||||
|
|
||||||
productStockSchema.statics.history = async function (from, to) {
|
|
||||||
const results = await aggregateRollupsHistory({
|
|
||||||
model: this,
|
|
||||||
startDate: from,
|
|
||||||
endDate: to,
|
|
||||||
rollupConfigs: rollupConfigs,
|
|
||||||
});
|
|
||||||
|
|
||||||
return results;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add virtual id getter
|
|
||||||
productStockSchema.virtual('id').get(function () {
|
|
||||||
return this._id;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Configure JSON serialization to include virtuals
|
|
||||||
productStockSchema.set('toJSON', { virtuals: true });
|
|
||||||
|
|
||||||
// Create and export the model
|
|
||||||
export const productStockModel = mongoose.model('productStock', productStockSchema);
|
|
||||||
@ -1,102 +1,22 @@
|
|||||||
import mongoose from 'mongoose';
|
import mongoose from 'mongoose';
|
||||||
import { generateId } from '../../utils.js';
|
import { generateId } from '../../utils.js';
|
||||||
const { Schema } = mongoose;
|
const { Schema } = mongoose;
|
||||||
import { aggregateRollups, aggregateRollupsHistory } from '../../database.js';
|
|
||||||
|
|
||||||
const purchaseOrderSchema = new Schema(
|
const purchaseOrderSchema = new Schema(
|
||||||
{
|
{
|
||||||
_reference: { type: String, default: () => generateId()() },
|
_reference: { type: String, default: () => generateId()() },
|
||||||
totalAmount: { type: Number, required: true, default: 0 },
|
totalAmount: { type: Number, required: true },
|
||||||
totalAmountWithTax: { type: Number, required: true, default: 0 },
|
totalAmountWithTax: { type: Number, required: true },
|
||||||
shippingAmount: { type: Number, required: true, default: 0 },
|
totalTaxAmount: { type: Number, required: true },
|
||||||
shippingAmountWithTax: { type: Number, required: true, default: 0 },
|
|
||||||
grandTotalAmount: { type: Number, required: true, default: 0 },
|
|
||||||
totalTaxAmount: { type: Number, required: true, default: 0 },
|
|
||||||
timestamp: { type: Date, default: Date.now },
|
timestamp: { type: Date, default: Date.now },
|
||||||
vendor: { type: Schema.Types.ObjectId, ref: 'vendor', required: true },
|
vendor: { type: Schema.Types.ObjectId, ref: 'vendor', required: true },
|
||||||
state: {
|
state: {
|
||||||
type: { type: String, required: true, default: 'draft' },
|
type: { type: String, required: true, default: 'draft' },
|
||||||
},
|
},
|
||||||
postedAt: { type: Date, required: false },
|
|
||||||
acknowledgedAt: { type: Date, required: false },
|
|
||||||
cancelledAt: { type: Date, required: false },
|
|
||||||
completedAt: { type: Date, required: false },
|
|
||||||
},
|
},
|
||||||
{ timestamps: true }
|
{ timestamps: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
purchaseOrderSchema.index({ 'state.type': 'text' });
|
|
||||||
|
|
||||||
const rollupConfigs = [
|
|
||||||
{
|
|
||||||
name: 'draft',
|
|
||||||
filter: { 'state.type': 'draft' },
|
|
||||||
rollups: [{ name: 'draft', property: 'state.type', operation: 'count' }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'sent',
|
|
||||||
filter: { 'state.type': 'sent' },
|
|
||||||
rollups: [{ name: 'sent', property: 'state.type', operation: 'count' }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'acknowledged',
|
|
||||||
filter: { 'state.type': 'acknowledged' },
|
|
||||||
rollups: [{ name: 'acknowledged', property: 'state.type', operation: 'count' }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'partiallyShipped',
|
|
||||||
filter: { 'state.type': 'partiallyShipped' },
|
|
||||||
rollups: [{ name: 'partiallyShipped', property: 'state.type', operation: 'count' }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'shipped',
|
|
||||||
filter: { 'state.type': 'shipped' },
|
|
||||||
rollups: [{ name: 'shipped', property: 'state.type', operation: 'count' }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'partiallyReceived',
|
|
||||||
filter: { 'state.type': 'partiallyReceived' },
|
|
||||||
rollups: [{ name: 'partiallyReceived', property: 'state.type', operation: 'count' }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'received',
|
|
||||||
filter: { 'state.type': 'received' },
|
|
||||||
rollups: [{ name: 'received', property: 'state.type', operation: 'count' }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'cancelled',
|
|
||||||
filter: { 'state.type': 'cancelled' },
|
|
||||||
rollups: [{ name: 'cancelled', property: 'state.type', operation: 'count' }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'completed',
|
|
||||||
filter: { 'state.type': 'completed' },
|
|
||||||
rollups: [{ name: 'completed', property: 'state.type', operation: 'count' }],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
purchaseOrderSchema.statics.stats = async function () {
|
|
||||||
const results = await aggregateRollups({
|
|
||||||
model: this,
|
|
||||||
rollupConfigs: rollupConfigs,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Transform the results to match the expected format
|
|
||||||
return results;
|
|
||||||
};
|
|
||||||
|
|
||||||
purchaseOrderSchema.statics.history = async function (from, to) {
|
|
||||||
const results = await aggregateRollupsHistory({
|
|
||||||
model: this,
|
|
||||||
startDate: from,
|
|
||||||
endDate: to,
|
|
||||||
rollupConfigs: rollupConfigs,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Return time-series data array
|
|
||||||
return results;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add virtual id getter
|
// Add virtual id getter
|
||||||
purchaseOrderSchema.virtual('id').get(function () {
|
purchaseOrderSchema.virtual('id').get(function () {
|
||||||
return this._id;
|
return this._id;
|
||||||
|
|||||||
@ -1,119 +1,43 @@
|
|||||||
import mongoose from 'mongoose';
|
import mongoose from 'mongoose';
|
||||||
import { generateId } from '../../utils.js';
|
import { generateId } from '../../utils.js';
|
||||||
const { Schema } = mongoose;
|
const { Schema } = mongoose;
|
||||||
import { purchaseOrderModel } from './purchaseorder.schema.js';
|
|
||||||
import { salesOrderModel } from '../sales/salesorder.schema.js';
|
const shipmentItemSchema = new Schema({
|
||||||
import { taxRateModel } from '../management/taxrate.schema.js';
|
itemType: { type: String, required: true },
|
||||||
import { aggregateRollups, editObject, getObject } from '../../database.js';
|
item: { type: Schema.Types.ObjectId, refPath: 'itemType', required: true },
|
||||||
|
quantity: { type: Number, required: true },
|
||||||
|
itemCost: { type: Number, required: true },
|
||||||
|
totalCost: { type: Number, required: true },
|
||||||
|
totalCostWithTax: { type: Number, required: true },
|
||||||
|
taxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
|
||||||
|
});
|
||||||
|
|
||||||
const shipmentSchema = new Schema(
|
const shipmentSchema = new Schema(
|
||||||
{
|
{
|
||||||
_reference: { type: String, default: () => generateId()() },
|
_reference: { type: String, default: () => generateId()() },
|
||||||
orderType: { type: String, required: true },
|
purchaseOrder: { type: Schema.Types.ObjectId, ref: 'purchaseOrder', required: true },
|
||||||
order: { type: Schema.Types.ObjectId, refPath: 'orderType', required: true },
|
vendor: { type: Schema.Types.ObjectId, ref: 'vendor', required: true },
|
||||||
courierService: { type: Schema.Types.ObjectId, ref: 'courierService', required: false },
|
courierService: { type: Schema.Types.ObjectId, ref: 'courierService', required: false },
|
||||||
trackingNumber: { type: String, required: false },
|
trackingNumber: { type: String, required: false },
|
||||||
amount: { type: Number, required: true },
|
items: [shipmentItemSchema],
|
||||||
amountWithTax: { type: Number, required: true },
|
cost: { net: { type: Number, required: true }, gross: { type: Number, required: true } },
|
||||||
taxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
|
shippedDate: { type: Date, required: false },
|
||||||
invoicedAmount: { type: Number, required: false, default: 0 },
|
expectedDeliveryDate: { type: Date, required: false },
|
||||||
invoicedAmountWithTax: { type: Number, required: false, default: 0 },
|
actualDeliveryDate: { type: Date, required: false },
|
||||||
invoicedAmountRemaining: { type: Number, required: false, default: 0 },
|
|
||||||
invoicedAmountWithTaxRemaining: { type: Number, required: false, default: 0 },
|
|
||||||
shippedAt: { type: Date, required: false },
|
|
||||||
expectedAt: { type: Date, required: false },
|
|
||||||
deliveredAt: { type: Date, required: false },
|
|
||||||
cancelledAt: { type: Date, required: false },
|
|
||||||
state: {
|
state: {
|
||||||
type: {
|
type: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
|
default: 'pending',
|
||||||
|
enum: ['pending', 'shipped', 'in_transit', 'delivered', 'cancelled'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
notes: { type: String },
|
||||||
|
timestamp: { type: Date, default: Date.now },
|
||||||
},
|
},
|
||||||
{ timestamps: true }
|
{ timestamps: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
shipmentSchema.index({ trackingNumber: 'text', orderType: 'text' });
|
|
||||||
|
|
||||||
shipmentSchema.statics.recalculate = async function (shipment, user) {
|
|
||||||
if (shipment.orderType !== 'purchaseOrder' && shipment.orderType !== 'salesOrder') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const orderId = shipment.order?._id || shipment.order;
|
|
||||||
if (!orderId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var taxRate = shipment.taxRate;
|
|
||||||
|
|
||||||
if (shipment.taxRate?._id && Object.keys(shipment.taxRate).length == 1) {
|
|
||||||
taxRate = await getObject({
|
|
||||||
model: taxRateModel,
|
|
||||||
id: shipment.taxRate._id,
|
|
||||||
cached: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const amountWithTax = parseFloat(
|
|
||||||
(shipment.amount || 0) * (1 + (taxRate?.rate || 0) / 100)
|
|
||||||
).toFixed(2);
|
|
||||||
await editObject({
|
|
||||||
model: shipmentModel,
|
|
||||||
id: shipment._id,
|
|
||||||
updateData: {
|
|
||||||
amountWithTax: amountWithTax,
|
|
||||||
invoicedAmountRemaining: shipment.amount - (shipment.invoicedAmount || 0),
|
|
||||||
invoicedAmountWithTaxRemaining: amountWithTax - (shipment.invoicedAmountWithTax || 0),
|
|
||||||
},
|
|
||||||
user,
|
|
||||||
recalculate: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const rollupResults = await aggregateRollups({
|
|
||||||
model: this,
|
|
||||||
baseFilter: {
|
|
||||||
order: new mongoose.Types.ObjectId(orderId),
|
|
||||||
orderType: shipment.orderType,
|
|
||||||
},
|
|
||||||
rollupConfigs: [
|
|
||||||
{
|
|
||||||
name: 'shipmentTotals',
|
|
||||||
rollups: [
|
|
||||||
{ name: 'amount', property: 'amount', operation: 'sum' },
|
|
||||||
{ name: 'amountWithTax', property: 'amountWithTax', operation: 'sum' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const totals = rollupResults.shipmentTotals || {};
|
|
||||||
const totalShippingAmount = totals.amount.sum?.toFixed(2) || 0;
|
|
||||||
const totalShippingAmountWithTax = totals.amountWithTax.sum?.toFixed(2) || 0;
|
|
||||||
|
|
||||||
const orderModel = shipment.orderType === 'purchaseOrder' ? purchaseOrderModel : salesOrderModel;
|
|
||||||
const order = await getObject({
|
|
||||||
model: orderModel,
|
|
||||||
id: orderId,
|
|
||||||
cached: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const grandTotalAmount =
|
|
||||||
parseFloat(order.totalAmountWithTax || 0) + parseFloat(totalShippingAmountWithTax || 0);
|
|
||||||
await editObject({
|
|
||||||
model: orderModel,
|
|
||||||
id: orderId,
|
|
||||||
updateData: {
|
|
||||||
shippingAmount: parseFloat(totalShippingAmount).toFixed(2),
|
|
||||||
shippingAmountWithTax: parseFloat(totalShippingAmountWithTax).toFixed(2),
|
|
||||||
grandTotalAmount: parseFloat(grandTotalAmount).toFixed(2),
|
|
||||||
},
|
|
||||||
user,
|
|
||||||
recalculate: false,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add virtual id getter
|
// Add virtual id getter
|
||||||
shipmentSchema.virtual('id').get(function () {
|
shipmentSchema.virtual('id').get(function () {
|
||||||
return this._id;
|
return this._id;
|
||||||
|
|||||||
@ -28,8 +28,6 @@ const stockAuditSchema = new Schema(
|
|||||||
{ timestamps: true }
|
{ timestamps: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
stockAuditSchema.index({ type: 'text', status: 'text', notes: 'text' });
|
|
||||||
|
|
||||||
// Add virtual id getter
|
// Add virtual id getter
|
||||||
stockAuditSchema.virtual('id').get(function () {
|
stockAuditSchema.virtual('id').get(function () {
|
||||||
return this._id;
|
return this._id;
|
||||||
|
|||||||
@ -1,199 +1,7 @@
|
|||||||
import mongoose from 'mongoose';
|
import mongoose from 'mongoose';
|
||||||
import { generateId } from '../../utils.js';
|
import { generateId } from '../../utils.js';
|
||||||
import { getObject, editObject } from '../../database.js';
|
|
||||||
const { Schema } = mongoose;
|
const { Schema } = mongoose;
|
||||||
|
|
||||||
const parentStockModelNames = {
|
|
||||||
filamentStock: 'filamentStock',
|
|
||||||
partStock: 'partStock',
|
|
||||||
productStock: 'productStock',
|
|
||||||
};
|
|
||||||
|
|
||||||
const initialStockStates = {
|
|
||||||
filamentStock: 'unconsumed',
|
|
||||||
partStock: 'new',
|
|
||||||
productStock: 'posted',
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStartingAmount = (parentType, parentStock) => {
|
|
||||||
if (parentType === 'filamentStock') {
|
|
||||||
return parentStock.startingWeight?.net ?? 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return parentStock.startingQuantity ?? 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildParentState = (parentType, parentStock, currentAmount, startingAmount) => {
|
|
||||||
if (parentStock.state?.type === 'draft') {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fullState = initialStockStates[parentType];
|
|
||||||
if (!fullState) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentAmount <= 0) {
|
|
||||||
return { ...parentStock.state, type: 'consumed', progress: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (startingAmount <= 0) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const progress = currentAmount / startingAmount;
|
|
||||||
|
|
||||||
console.log('progress', progress);
|
|
||||||
|
|
||||||
if (currentAmount === startingAmount) {
|
|
||||||
return { ...parentStock.state, type: fullState, progress: 1 };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentAmount < startingAmount) {
|
|
||||||
return { ...parentStock.state, type: 'used', progress };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ...parentStock.state, type: fullState, progress: 1 };
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStockEventTotal = async (parentId, parentType) => {
|
|
||||||
if (!parentId) return null;
|
|
||||||
|
|
||||||
const objectId =
|
|
||||||
parentId instanceof mongoose.Types.ObjectId ? parentId : new mongoose.Types.ObjectId(parentId);
|
|
||||||
|
|
||||||
const [result] = await mongoose
|
|
||||||
.model('stockEvent')
|
|
||||||
.aggregate([
|
|
||||||
{ $match: { parent: objectId, parentType } },
|
|
||||||
{ $group: { _id: null, total: { $sum: '$value' }, count: { $sum: 1 } } },
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
total: result?.total ?? 0,
|
|
||||||
count: result?.count ?? 0,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildParentUpdateData = (parentType, parentStock, events) => {
|
|
||||||
const updateData = {};
|
|
||||||
let currentAmount;
|
|
||||||
|
|
||||||
if (parentType === 'filamentStock') {
|
|
||||||
const net = events.total;
|
|
||||||
const startingNet = parentStock.startingWeight?.net ?? 0;
|
|
||||||
const startingGross = parentStock.startingWeight?.gross ?? 0;
|
|
||||||
const gross = startingNet > 0 ? (startingGross * net) / startingNet : net;
|
|
||||||
updateData.currentWeight = { net, gross };
|
|
||||||
currentAmount = net;
|
|
||||||
} else {
|
|
||||||
updateData.currentQuantity = events.total;
|
|
||||||
currentAmount = events.total;
|
|
||||||
}
|
|
||||||
|
|
||||||
const state = buildParentState(
|
|
||||||
parentType,
|
|
||||||
parentStock,
|
|
||||||
currentAmount,
|
|
||||||
getStartingAmount(parentType, parentStock)
|
|
||||||
);
|
|
||||||
if (state) {
|
|
||||||
updateData.state = state;
|
|
||||||
}
|
|
||||||
|
|
||||||
return updateData;
|
|
||||||
};
|
|
||||||
|
|
||||||
const HISTORY_RATE_LIMIT_MS = 3000;
|
|
||||||
|
|
||||||
const isWithinHistoryRateLimit = (lastEntry, timestamp = new Date()) => {
|
|
||||||
if (!lastEntry?.timestamp) return false;
|
|
||||||
const elapsed = new Date(timestamp).getTime() - new Date(lastEntry.timestamp).getTime();
|
|
||||||
return elapsed < HISTORY_RATE_LIMIT_MS;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getLastParentHistoryValue = (parentType, history = []) => {
|
|
||||||
const lastEntry = history.at(-1);
|
|
||||||
if (!lastEntry) return undefined;
|
|
||||||
|
|
||||||
return parentType === 'filamentStock' ? lastEntry.currentWeight : lastEntry.currentQuantity;
|
|
||||||
};
|
|
||||||
|
|
||||||
const parentValuesEqual = (parentType, a, b) => {
|
|
||||||
if (a === b) return true;
|
|
||||||
if (a == null || b == null) return false;
|
|
||||||
|
|
||||||
if (parentType === 'filamentStock') {
|
|
||||||
return a.net === b.net && a.gross === b.gross;
|
|
||||||
}
|
|
||||||
|
|
||||||
return a === b;
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildParentHistoryEntry = (parentType, currentValue, timestamp) => {
|
|
||||||
if (parentType === 'filamentStock') {
|
|
||||||
return { currentWeight: currentValue, timestamp };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { currentQuantity: currentValue, timestamp };
|
|
||||||
};
|
|
||||||
|
|
||||||
const appendParentHistoryIfChanged = (
|
|
||||||
parentType,
|
|
||||||
parentStock,
|
|
||||||
updateData,
|
|
||||||
timestamp = new Date()
|
|
||||||
) => {
|
|
||||||
const history = parentStock.history || [];
|
|
||||||
const lastEntry = history.at(-1);
|
|
||||||
const currentValue =
|
|
||||||
parentType === 'filamentStock' ? updateData.currentWeight : updateData.currentQuantity;
|
|
||||||
const lastHistoryValue = getLastParentHistoryValue(parentType, history);
|
|
||||||
|
|
||||||
if (parentValuesEqual(parentType, currentValue, lastHistoryValue)) {
|
|
||||||
return updateData;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isWithinHistoryRateLimit(lastEntry, timestamp)) {
|
|
||||||
return updateData;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...updateData,
|
|
||||||
history: [...history, buildParentHistoryEntry(parentType, currentValue, timestamp)],
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const recalculateParentStock = async (parentType, parentId, user) => {
|
|
||||||
if (!parentType || !parentId) return;
|
|
||||||
|
|
||||||
const modelName = parentStockModelNames[parentType];
|
|
||||||
if (!modelName) return;
|
|
||||||
|
|
||||||
const parentModel = mongoose.model(modelName);
|
|
||||||
const parentStock = await getObject({
|
|
||||||
model: parentModel,
|
|
||||||
id: parentId,
|
|
||||||
cached: true,
|
|
||||||
});
|
|
||||||
if (!parentStock || parentStock.error) return;
|
|
||||||
|
|
||||||
const events = await getStockEventTotal(parentId, parentType);
|
|
||||||
if (!events?.count) return;
|
|
||||||
|
|
||||||
await editObject({
|
|
||||||
model: parentModel,
|
|
||||||
id: parentStock._id,
|
|
||||||
updateData: appendParentHistoryIfChanged(
|
|
||||||
parentType,
|
|
||||||
parentStock,
|
|
||||||
buildParentUpdateData(parentType, parentStock, events)
|
|
||||||
),
|
|
||||||
user,
|
|
||||||
recalculate: false,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const stockEventSchema = new Schema(
|
const stockEventSchema = new Schema(
|
||||||
{
|
{
|
||||||
_reference: { type: String, default: () => generateId()() },
|
_reference: { type: String, default: () => generateId()() },
|
||||||
@ -217,45 +25,13 @@ const stockEventSchema = new Schema(
|
|||||||
ownerType: {
|
ownerType: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
enum: ['user', 'subJob', 'stockAudit', 'stockTransfer'],
|
enum: ['user', 'subJob', 'stockAudit'],
|
||||||
},
|
},
|
||||||
history: [
|
|
||||||
{
|
|
||||||
value: { type: Number, required: true },
|
|
||||||
timestamp: { type: Date, default: Date.now },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
timestamp: { type: Date, default: Date.now },
|
timestamp: { type: Date, default: Date.now },
|
||||||
},
|
},
|
||||||
{ timestamps: true }
|
{ timestamps: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
stockEventSchema.index({ parentType: 'text', ownerType: 'text', unit: 'text' });
|
|
||||||
|
|
||||||
stockEventSchema.statics.recalculate = async function (stockEvent, user) {
|
|
||||||
const history = stockEvent.history || [];
|
|
||||||
const lastEntry = history.at(-1);
|
|
||||||
const lastHistoryValue = lastEntry?.value;
|
|
||||||
const currentValue = stockEvent.value;
|
|
||||||
const timestamp = stockEvent.timestamp || new Date();
|
|
||||||
|
|
||||||
if (currentValue !== lastHistoryValue && !isWithinHistoryRateLimit(lastEntry, timestamp)) {
|
|
||||||
await editObject({
|
|
||||||
model: this,
|
|
||||||
id: stockEvent._id,
|
|
||||||
updateData: {
|
|
||||||
history: [...history, { value: currentValue, timestamp }],
|
|
||||||
},
|
|
||||||
user,
|
|
||||||
recalculate: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const parentType = stockEvent.parentType;
|
|
||||||
const parentId = stockEvent.parent?._id || stockEvent.parent;
|
|
||||||
await recalculateParentStock(parentType, parentId, user);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add virtual id getter
|
// Add virtual id getter
|
||||||
stockEventSchema.virtual('id').get(function () {
|
stockEventSchema.virtual('id').get(function () {
|
||||||
return this._id;
|
return this._id;
|
||||||
|
|||||||
@ -1,41 +0,0 @@
|
|||||||
import mongoose from 'mongoose';
|
|
||||||
import { generateId } from '../../utils.js';
|
|
||||||
const { Schema } = mongoose;
|
|
||||||
|
|
||||||
const addressSchema = new Schema({
|
|
||||||
building: { required: false, type: String },
|
|
||||||
addressLine1: { required: false, type: String },
|
|
||||||
addressLine2: { required: false, type: String },
|
|
||||||
city: { required: false, type: String },
|
|
||||||
state: { required: false, type: String },
|
|
||||||
postcode: { required: false, type: String },
|
|
||||||
country: { required: false, type: String },
|
|
||||||
});
|
|
||||||
|
|
||||||
const stockLocationSchema = new Schema(
|
|
||||||
{
|
|
||||||
_reference: { type: String, default: () => generateId()() },
|
|
||||||
name: { type: String, required: true },
|
|
||||||
address: { required: false, type: addressSchema },
|
|
||||||
},
|
|
||||||
{ timestamps: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
stockLocationSchema.index({ name: 'text' });
|
|
||||||
|
|
||||||
stockLocationSchema.statics.stats = async function () {
|
|
||||||
const total = await this.countDocuments({});
|
|
||||||
return { total: { count: total } };
|
|
||||||
};
|
|
||||||
|
|
||||||
stockLocationSchema.statics.history = async function () {
|
|
||||||
return [];
|
|
||||||
};
|
|
||||||
|
|
||||||
stockLocationSchema.virtual('id').get(function () {
|
|
||||||
return this._id;
|
|
||||||
});
|
|
||||||
|
|
||||||
stockLocationSchema.set('toJSON', { virtuals: true });
|
|
||||||
|
|
||||||
export const stockLocationModel = mongoose.model('stockLocation', stockLocationSchema);
|
|
||||||
@ -1,74 +0,0 @@
|
|||||||
import mongoose from 'mongoose';
|
|
||||||
import { generateId } from '../../utils.js';
|
|
||||||
const { Schema } = mongoose;
|
|
||||||
|
|
||||||
const stockTransferLineSchema = new Schema(
|
|
||||||
{
|
|
||||||
fromStockType: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
enum: ['filamentStock', 'partStock', 'productStock'],
|
|
||||||
},
|
|
||||||
fromStock: {
|
|
||||||
type: Schema.Types.ObjectId,
|
|
||||||
refPath: 'lines.fromStockType',
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
quantity: { type: Number, required: true },
|
|
||||||
toStockLocation: {
|
|
||||||
type: Schema.Types.ObjectId,
|
|
||||||
ref: 'stockLocation',
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
toStockType: {
|
|
||||||
type: String,
|
|
||||||
required: false,
|
|
||||||
enum: ['filamentStock', 'partStock', 'productStock'],
|
|
||||||
},
|
|
||||||
toStock: {
|
|
||||||
type: Schema.Types.ObjectId,
|
|
||||||
refPath: 'lines.toStockType',
|
|
||||||
required: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ _id: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
const stockTransferSchema = new Schema(
|
|
||||||
{
|
|
||||||
_reference: { type: String, default: () => generateId()() },
|
|
||||||
name: { type: String, required: true },
|
|
||||||
state: {
|
|
||||||
type: { type: String, required: true, default: 'draft' },
|
|
||||||
progress: { type: Number, required: false },
|
|
||||||
},
|
|
||||||
postedAt: { type: Date, required: false },
|
|
||||||
lines: { type: [stockTransferLineSchema], default: [] },
|
|
||||||
},
|
|
||||||
{ timestamps: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
stockTransferSchema.index({ name: 'text' });
|
|
||||||
|
|
||||||
stockTransferSchema.statics.stats = async function () {
|
|
||||||
const [draft, posted] = await Promise.all([
|
|
||||||
this.countDocuments({ 'state.type': 'draft' }),
|
|
||||||
this.countDocuments({ 'state.type': 'posted' }),
|
|
||||||
]);
|
|
||||||
return {
|
|
||||||
draft: { count: draft },
|
|
||||||
posted: { count: posted },
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
stockTransferSchema.statics.history = async function () {
|
|
||||||
return [];
|
|
||||||
};
|
|
||||||
|
|
||||||
stockTransferSchema.virtual('id').get(function () {
|
|
||||||
return this._id;
|
|
||||||
});
|
|
||||||
|
|
||||||
stockTransferSchema.set('toJSON', { virtuals: true });
|
|
||||||
|
|
||||||
export const stockTransferModel = mongoose.model('stockTransfer', stockTransferSchema);
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
import mongoose from 'mongoose';
|
|
||||||
import { generateId } from '../../utils.js';
|
|
||||||
const { Schema } = mongoose;
|
|
||||||
|
|
||||||
const appPasswordSchema = new mongoose.Schema(
|
|
||||||
{
|
|
||||||
_reference: { type: String, default: () => generateId()() },
|
|
||||||
name: { type: String, required: true },
|
|
||||||
user: { type: Schema.Types.ObjectId, ref: 'user', required: true },
|
|
||||||
active: { type: Boolean, required: true, default: true },
|
|
||||||
secret: { type: String, required: true, select: false },
|
|
||||||
},
|
|
||||||
{ timestamps: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
appPasswordSchema.index({ name: 'text' });
|
|
||||||
|
|
||||||
appPasswordSchema.virtual('id').get(function () {
|
|
||||||
return this._id;
|
|
||||||
});
|
|
||||||
|
|
||||||
appPasswordSchema.set('toJSON', { virtuals: true });
|
|
||||||
|
|
||||||
export const appPasswordModel = mongoose.model('appPassword', appPasswordSchema);
|
|
||||||
@ -36,8 +36,6 @@ const auditLogSchema = new Schema(
|
|||||||
{ timestamps: true }
|
{ timestamps: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
auditLogSchema.index({ operation: 'text', parentType: 'text', ownerType: 'text' });
|
|
||||||
|
|
||||||
// Add virtual id getter
|
// Add virtual id getter
|
||||||
auditLogSchema.virtual('id').get(function () {
|
auditLogSchema.virtual('id').get(function () {
|
||||||
return this._id;
|
return this._id;
|
||||||
|
|||||||
@ -14,8 +14,6 @@ const courierSchema = new mongoose.Schema(
|
|||||||
{ timestamps: true }
|
{ timestamps: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
courierSchema.index({ name: 'text', website: 'text', email: 'text', phone: 'text', contact: 'text', country: 'text' });
|
|
||||||
|
|
||||||
courierSchema.virtual('id').get(function () {
|
courierSchema.virtual('id').get(function () {
|
||||||
return this._id;
|
return this._id;
|
||||||
});
|
});
|
||||||
|
|||||||
@ -11,19 +11,10 @@ const courierServiceSchema = new mongoose.Schema(
|
|||||||
tracked: { required: true, type: Boolean },
|
tracked: { required: true, type: Boolean },
|
||||||
deliveryTime: { required: true, type: Number },
|
deliveryTime: { required: true, type: Number },
|
||||||
website: { required: false, type: String },
|
website: { required: false, type: String },
|
||||||
cost: { required: true, type: Number, default: 0 },
|
|
||||||
costTaxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
|
|
||||||
costWithTax: { required: false, type: Number },
|
|
||||||
additionalCost: { required: false, type: Number },
|
|
||||||
additionalCostWithTax: { required: false, type: Number },
|
|
||||||
shippingCurrency: { required: true, type: String, default: 'GBP' },
|
|
||||||
international: { required: true, type: Boolean, default: false },
|
|
||||||
},
|
},
|
||||||
{ timestamps: true }
|
{ timestamps: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
courierServiceSchema.index({ name: 'text', website: 'text' });
|
|
||||||
|
|
||||||
courierServiceSchema.virtual('id').get(function () {
|
courierServiceSchema.virtual('id').get(function () {
|
||||||
return this._id;
|
return this._id;
|
||||||
});
|
});
|
||||||
|
|||||||
@ -39,8 +39,6 @@ const documentJobSchema = new Schema(
|
|||||||
{ timestamps: true }
|
{ timestamps: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
documentJobSchema.index({ name: 'text', objectType: 'text' });
|
|
||||||
|
|
||||||
// Add virtual id getter
|
// Add virtual id getter
|
||||||
documentJobSchema.virtual('id').get(function () {
|
documentJobSchema.virtual('id').get(function () {
|
||||||
return this._id;
|
return this._id;
|
||||||
|
|||||||
@ -38,8 +38,6 @@ const documentPrinterSchema = new Schema(
|
|||||||
{ timestamps: true }
|
{ timestamps: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
documentPrinterSchema.index({ name: 'text', tags: 'text' });
|
|
||||||
|
|
||||||
// Add virtual id getter
|
// Add virtual id getter
|
||||||
documentPrinterSchema.virtual('id').get(function () {
|
documentPrinterSchema.virtual('id').get(function () {
|
||||||
return this._id;
|
return this._id;
|
||||||
|
|||||||
@ -29,8 +29,6 @@ const documentSizeSchema = new Schema(
|
|||||||
{ timestamps: true }
|
{ timestamps: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
documentSizeSchema.index({ name: 'text' });
|
|
||||||
|
|
||||||
// Add virtual id getter
|
// Add virtual id getter
|
||||||
documentSizeSchema.virtual('id').get(function () {
|
documentSizeSchema.virtual('id').get(function () {
|
||||||
return this._id;
|
return this._id;
|
||||||
|
|||||||
@ -52,8 +52,6 @@ const documentTemplateSchema = new Schema(
|
|||||||
{ timestamps: true }
|
{ timestamps: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
documentTemplateSchema.index({ name: 'text', tags: 'text', objectType: 'text' });
|
|
||||||
|
|
||||||
// Add virtual id getter
|
// Add virtual id getter
|
||||||
documentTemplateSchema.virtual('id').get(function () {
|
documentTemplateSchema.virtual('id').get(function () {
|
||||||
return this._id;
|
return this._id;
|
||||||
|
|||||||
@ -2,24 +2,24 @@ import mongoose from 'mongoose';
|
|||||||
import { generateId } from '../../utils.js';
|
import { generateId } from '../../utils.js';
|
||||||
const { Schema } = mongoose;
|
const { Schema } = mongoose;
|
||||||
|
|
||||||
// Filament base - cost and tax; color and cost override at FilamentSKU
|
|
||||||
const filamentSchema = new mongoose.Schema({
|
const filamentSchema = new mongoose.Schema({
|
||||||
_reference: { type: String, default: () => generateId()() },
|
_reference: { type: String, default: () => generateId()() },
|
||||||
name: { required: true, type: String },
|
name: { required: true, type: String },
|
||||||
vendor: { type: Schema.Types.ObjectId, ref: 'vendor', required: false },
|
|
||||||
barcode: { required: false, type: String },
|
barcode: { required: false, type: String },
|
||||||
url: { required: false, type: String },
|
url: { required: false, type: String },
|
||||||
image: { required: false, type: Buffer },
|
image: { required: false, type: Buffer },
|
||||||
material: { type: Schema.Types.ObjectId, ref: 'material', required: true },
|
color: { required: true, type: String },
|
||||||
|
vendor: { type: Schema.Types.ObjectId, ref: 'vendor', required: true },
|
||||||
|
type: { required: true, type: String },
|
||||||
|
cost: { required: true, type: Number },
|
||||||
|
costTaxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: true },
|
||||||
|
costWithTax: { required: true, type: Number },
|
||||||
diameter: { required: true, type: Number },
|
diameter: { required: true, type: Number },
|
||||||
density: { required: true, type: Number },
|
density: { required: true, type: Number },
|
||||||
|
createdAt: { required: true, type: Date },
|
||||||
|
updatedAt: { required: true, type: Date },
|
||||||
emptySpoolWeight: { required: true, type: Number },
|
emptySpoolWeight: { required: true, type: Number },
|
||||||
cost: { type: Number, required: false },
|
});
|
||||||
costTaxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
|
|
||||||
costWithTax: { type: Number, required: false },
|
|
||||||
}, { timestamps: true });
|
|
||||||
|
|
||||||
filamentSchema.index({ name: 'text', barcode: 'text', url: 'text' });
|
|
||||||
|
|
||||||
filamentSchema.virtual('id').get(function () {
|
filamentSchema.virtual('id').get(function () {
|
||||||
return this._id;
|
return this._id;
|
||||||
@ -27,12 +27,4 @@ filamentSchema.virtual('id').get(function () {
|
|||||||
|
|
||||||
filamentSchema.set('toJSON', { virtuals: true });
|
filamentSchema.set('toJSON', { virtuals: true });
|
||||||
|
|
||||||
filamentSchema.statics.recalculate = async function (filament, user) {
|
|
||||||
const filamentSkuModel = mongoose.model('filamentSku');
|
|
||||||
const skus = await filamentSkuModel.find({ filament: filament._id }).select('_id').lean();
|
|
||||||
for (const sku of skus) {
|
|
||||||
await filamentSkuModel.recalculate(sku, user);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const filamentModel = mongoose.model('filament', filamentSchema);
|
export const filamentModel = mongoose.model('filament', filamentSchema);
|
||||||
|
|||||||
@ -1,50 +0,0 @@
|
|||||||
import mongoose from 'mongoose';
|
|
||||||
import { generateId } from '../../utils.js';
|
|
||||||
const { Schema } = mongoose;
|
|
||||||
|
|
||||||
// Define the main filament SKU schema - color and cost live at SKU level
|
|
||||||
const filamentSkuSchema = new Schema(
|
|
||||||
{
|
|
||||||
_reference: { type: String, default: () => generateId()() },
|
|
||||||
barcode: { type: String, required: false },
|
|
||||||
filament: { type: Schema.Types.ObjectId, ref: 'filament', required: true },
|
|
||||||
name: { type: String, required: true },
|
|
||||||
description: { type: String, required: false },
|
|
||||||
color: { type: String, required: true },
|
|
||||||
cost: { type: Number, required: false },
|
|
||||||
overrideCost: { type: Boolean, default: false },
|
|
||||||
costTaxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
|
|
||||||
costWithTax: { type: Number, required: false },
|
|
||||||
},
|
|
||||||
{ timestamps: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
filamentSkuSchema.index({ name: 'text', barcode: 'text', description: 'text', color: 'text' });
|
|
||||||
|
|
||||||
// Add virtual id getter
|
|
||||||
filamentSkuSchema.virtual('id').get(function () {
|
|
||||||
return this._id;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Configure JSON serialization to include virtuals
|
|
||||||
filamentSkuSchema.set('toJSON', { virtuals: true });
|
|
||||||
|
|
||||||
filamentSkuSchema.statics.recalculate = async function (filamentSku, user) {
|
|
||||||
const orderItemModel = mongoose.model('orderItem');
|
|
||||||
const skuId = filamentSku._id;
|
|
||||||
const draftOrderItems = await orderItemModel
|
|
||||||
.find({
|
|
||||||
'state.type': 'draft',
|
|
||||||
itemType: 'filament',
|
|
||||||
sku: skuId,
|
|
||||||
})
|
|
||||||
.populate('order')
|
|
||||||
.lean();
|
|
||||||
|
|
||||||
for (const orderItem of draftOrderItems) {
|
|
||||||
await orderItemModel.recalculate(orderItem, user);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create and export the model
|
|
||||||
export const filamentSkuModel = mongoose.model('filamentSku', filamentSkuSchema);
|
|
||||||
@ -9,13 +9,10 @@ const fileSchema = new mongoose.Schema(
|
|||||||
extension: { required: true, type: String },
|
extension: { required: true, type: String },
|
||||||
size: { required: false, type: Number },
|
size: { required: false, type: Number },
|
||||||
metaData: { required: false, type: Object },
|
metaData: { required: false, type: Object },
|
||||||
hasThumbnails: { required: false, type: Boolean, default: false },
|
|
||||||
},
|
},
|
||||||
{ timestamps: true }
|
{ timestamps: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
fileSchema.index({ name: 'text', type: 'text', extension: 'text' });
|
|
||||||
|
|
||||||
fileSchema.virtual('id').get(function () {
|
fileSchema.virtual('id').get(function () {
|
||||||
return this._id;
|
return this._id;
|
||||||
});
|
});
|
||||||
|
|||||||
@ -56,15 +56,11 @@ const hostSchema = new mongoose.Schema(
|
|||||||
connectedAt: { required: false, type: Date },
|
connectedAt: { required: false, type: Date },
|
||||||
authCode: { type: { required: false, type: String } },
|
authCode: { type: { required: false, type: String } },
|
||||||
deviceInfo: { deviceInfoSchema },
|
deviceInfo: { deviceInfoSchema },
|
||||||
otp: { type: { required: false, type: String } },
|
|
||||||
otpExpiresAt: { required: false, type: Date },
|
|
||||||
files: [{ type: mongoose.Schema.Types.ObjectId, ref: 'file' }],
|
files: [{ type: mongoose.Schema.Types.ObjectId, ref: 'file' }],
|
||||||
},
|
},
|
||||||
{ timestamps: true }
|
{ timestamps: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
hostSchema.index({ name: 'text', tags: 'text' });
|
|
||||||
|
|
||||||
hostSchema.virtual('id').get(function () {
|
hostSchema.virtual('id').get(function () {
|
||||||
return this._id;
|
return this._id;
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,17 +1,13 @@
|
|||||||
import mongoose from 'mongoose';
|
import mongoose from 'mongoose';
|
||||||
import { generateId } from '../../utils.js';
|
import { generateId } from '../../utils.js';
|
||||||
|
|
||||||
const materialSchema = new mongoose.Schema(
|
const materialSchema = new mongoose.Schema({
|
||||||
{
|
|
||||||
_reference: { type: String, default: () => generateId()() },
|
_reference: { type: String, default: () => generateId()() },
|
||||||
name: { required: true, type: String },
|
name: { required: true, type: String },
|
||||||
url: { required: false, type: String },
|
url: { required: false, type: String },
|
||||||
|
image: { required: false, type: Buffer },
|
||||||
tags: [{ type: String }],
|
tags: [{ type: String }],
|
||||||
},
|
});
|
||||||
{ timestamps: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
materialSchema.index({ name: 'text', url: 'text', tags: 'text' });
|
|
||||||
|
|
||||||
materialSchema.virtual('id').get(function () {
|
materialSchema.virtual('id').get(function () {
|
||||||
return this._id;
|
return this._id;
|
||||||
|
|||||||
@ -23,8 +23,6 @@ const noteTypeSchema = new Schema(
|
|||||||
{ timestamps: true }
|
{ timestamps: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
noteTypeSchema.index({ name: 'text' });
|
|
||||||
|
|
||||||
// Add virtual id getter
|
// Add virtual id getter
|
||||||
noteTypeSchema.virtual('id').get(function () {
|
noteTypeSchema.virtual('id').get(function () {
|
||||||
return this._id;
|
return this._id;
|
||||||
|
|||||||
@ -2,27 +2,26 @@ import mongoose from 'mongoose';
|
|||||||
import { generateId } from '../../utils.js';
|
import { generateId } from '../../utils.js';
|
||||||
const { Schema } = mongoose;
|
const { Schema } = mongoose;
|
||||||
|
|
||||||
// Define the main part schema - cost/price and tax; override at PartSku
|
// Define the main part schema
|
||||||
const partSchema = new Schema(
|
const partSchema = new Schema(
|
||||||
{
|
{
|
||||||
_reference: { type: String, default: () => generateId()() },
|
_reference: { type: String, default: () => generateId()() },
|
||||||
name: { type: String, required: true },
|
name: { type: String, required: true },
|
||||||
file: { type: mongoose.SchemaTypes.ObjectId, ref: 'file', required: false },
|
fileName: { type: String, required: false },
|
||||||
cost: { type: Number, required: false },
|
|
||||||
price: { type: Number, required: false },
|
|
||||||
priceMode: { type: String, default: 'margin' },
|
priceMode: { type: String, default: 'margin' },
|
||||||
|
price: { type: Number, required: true },
|
||||||
|
cost: { type: Number, required: true },
|
||||||
margin: { type: Number, required: false },
|
margin: { type: Number, required: false },
|
||||||
amount: { type: Number, required: false },
|
vendor: { type: Schema.Types.ObjectId, ref: 'vendor', required: true },
|
||||||
costTaxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
|
file: { type: mongoose.SchemaTypes.ObjectId, ref: 'file', required: false },
|
||||||
priceTaxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
|
priceTaxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
|
||||||
costWithTax: { type: Number, required: false },
|
costTaxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
|
||||||
priceWithTax: { type: Number, required: false },
|
priceWithTax: { type: Number, required: false },
|
||||||
|
costWithTax: { type: Number, required: false },
|
||||||
},
|
},
|
||||||
{ timestamps: true }
|
{ timestamps: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
partSchema.index({ name: 'text' });
|
|
||||||
|
|
||||||
// Add virtual id getter
|
// Add virtual id getter
|
||||||
partSchema.virtual('id').get(function () {
|
partSchema.virtual('id').get(function () {
|
||||||
return this._id;
|
return this._id;
|
||||||
@ -31,13 +30,5 @@ partSchema.virtual('id').get(function () {
|
|||||||
// Configure JSON serialization to include virtuals
|
// Configure JSON serialization to include virtuals
|
||||||
partSchema.set('toJSON', { virtuals: true });
|
partSchema.set('toJSON', { virtuals: true });
|
||||||
|
|
||||||
partSchema.statics.recalculate = async function (part, user) {
|
|
||||||
const partSkuModel = mongoose.model('partSku');
|
|
||||||
const skus = await partSkuModel.find({ part: part._id }).select('_id').lean();
|
|
||||||
for (const sku of skus) {
|
|
||||||
await partSkuModel.recalculate(sku, user);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create and export the model
|
// Create and export the model
|
||||||
export const partModel = mongoose.model('part', partSchema);
|
export const partModel = mongoose.model('part', partSchema);
|
||||||
|
|||||||
@ -1,56 +0,0 @@
|
|||||||
import mongoose from 'mongoose';
|
|
||||||
import { generateId } from '../../utils.js';
|
|
||||||
const { Schema } = mongoose;
|
|
||||||
|
|
||||||
// Define the main part SKU schema - pricing lives at SKU level
|
|
||||||
const partSkuSchema = new Schema(
|
|
||||||
{
|
|
||||||
_reference: { type: String, default: () => generateId()() },
|
|
||||||
barcode: { type: String, required: false },
|
|
||||||
part: { type: Schema.Types.ObjectId, ref: 'part', required: true },
|
|
||||||
name: { type: String, required: true },
|
|
||||||
description: { type: String, required: false },
|
|
||||||
priceMode: { type: String, default: 'margin' },
|
|
||||||
price: { type: Number, required: false },
|
|
||||||
cost: { type: Number, required: false },
|
|
||||||
overrideCost: { type: Boolean, default: false },
|
|
||||||
overridePrice: { type: Boolean, default: false },
|
|
||||||
margin: { type: Number, required: false },
|
|
||||||
amount: { type: Number, required: false },
|
|
||||||
priceTaxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
|
|
||||||
costTaxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
|
|
||||||
priceWithTax: { type: Number, required: false },
|
|
||||||
costWithTax: { type: Number, required: false },
|
|
||||||
},
|
|
||||||
{ timestamps: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
partSkuSchema.index({ name: 'text', barcode: 'text', description: 'text' });
|
|
||||||
|
|
||||||
// Add virtual id getter
|
|
||||||
partSkuSchema.virtual('id').get(function () {
|
|
||||||
return this._id;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Configure JSON serialization to include virtuals
|
|
||||||
partSkuSchema.set('toJSON', { virtuals: true });
|
|
||||||
|
|
||||||
partSkuSchema.statics.recalculate = async function (partSku, user) {
|
|
||||||
const orderItemModel = mongoose.model('orderItem');
|
|
||||||
const skuId = partSku._id;
|
|
||||||
const draftOrderItems = await orderItemModel
|
|
||||||
.find({
|
|
||||||
'state.type': 'draft',
|
|
||||||
itemType: 'part',
|
|
||||||
sku: skuId,
|
|
||||||
})
|
|
||||||
.populate('order')
|
|
||||||
.lean();
|
|
||||||
|
|
||||||
for (const orderItem of draftOrderItems) {
|
|
||||||
await orderItemModel.recalculate(orderItem, user);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create and export the model
|
|
||||||
export const partSkuModel = mongoose.model('partSku', partSkuSchema);
|
|
||||||
@ -2,30 +2,28 @@ import mongoose from 'mongoose';
|
|||||||
import { generateId } from '../../utils.js';
|
import { generateId } from '../../utils.js';
|
||||||
const { Schema } = mongoose;
|
const { Schema } = mongoose;
|
||||||
|
|
||||||
|
const partSchema = new Schema({
|
||||||
|
part: { type: Schema.Types.ObjectId, ref: 'part', required: true },
|
||||||
|
quantity: { type: Number, required: true },
|
||||||
|
});
|
||||||
|
|
||||||
// Define the main product schema
|
// Define the main product schema
|
||||||
const productSchema = new Schema(
|
const productSchema = new Schema(
|
||||||
{
|
{
|
||||||
_reference: { type: String, default: () => generateId()() },
|
_reference: { type: String, default: () => generateId()() },
|
||||||
name: { type: String, required: true },
|
name: { type: String, required: true },
|
||||||
productCategory: { type: Schema.Types.ObjectId, ref: 'productCategory', required: true },
|
|
||||||
tags: [{ type: String }],
|
tags: [{ type: String }],
|
||||||
version: { type: String },
|
version: { type: String },
|
||||||
vendor: { type: Schema.Types.ObjectId, ref: 'vendor', required: true },
|
|
||||||
cost: { type: Number, required: false },
|
|
||||||
price: { type: Number, required: false },
|
|
||||||
priceMode: { type: String, default: 'margin' },
|
priceMode: { type: String, default: 'margin' },
|
||||||
margin: { type: Number, required: false },
|
margin: { type: Number, required: false },
|
||||||
amount: { type: Number, required: false },
|
amount: { type: Number, required: false },
|
||||||
costTaxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
|
vendor: { type: Schema.Types.ObjectId, ref: 'vendor', required: true },
|
||||||
|
parts: [partSchema],
|
||||||
priceTaxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
|
priceTaxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
|
||||||
costWithTax: { type: Number, required: false },
|
costTaxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
|
||||||
priceWithTax: { type: Number, required: false },
|
|
||||||
},
|
},
|
||||||
{ timestamps: true }
|
{ timestamps: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
productSchema.index({ name: 'text', tags: 'text', version: 'text' });
|
|
||||||
|
|
||||||
// Add virtual id getter
|
// Add virtual id getter
|
||||||
productSchema.virtual('id').get(function () {
|
productSchema.virtual('id').get(function () {
|
||||||
return this._id;
|
return this._id;
|
||||||
@ -34,23 +32,5 @@ productSchema.virtual('id').get(function () {
|
|||||||
// Configure JSON serialization to include virtuals
|
// Configure JSON serialization to include virtuals
|
||||||
productSchema.set('toJSON', { virtuals: true });
|
productSchema.set('toJSON', { virtuals: true });
|
||||||
|
|
||||||
productSchema.statics.recalculate = async function (product, user) {
|
|
||||||
const orderItemModel = mongoose.model('orderItem');
|
|
||||||
const itemId = product._id;
|
|
||||||
const draftOrderItems = await orderItemModel
|
|
||||||
.find({
|
|
||||||
'state.type': 'draft',
|
|
||||||
itemType: 'product',
|
|
||||||
item: itemId,
|
|
||||||
})
|
|
||||||
.populate('order')
|
|
||||||
.lean();
|
|
||||||
|
|
||||||
for (const orderItem of draftOrderItems) {
|
|
||||||
await orderItemModel.recalculate(orderItem, user);
|
|
||||||
}
|
|
||||||
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create and export the model
|
// Create and export the model
|
||||||
export const productModel = mongoose.model('product', productSchema);
|
export const productModel = mongoose.model('product', productSchema);
|
||||||
|
|||||||
@ -1,20 +0,0 @@
|
|||||||
import mongoose from 'mongoose';
|
|
||||||
import { generateId } from '../../utils.js';
|
|
||||||
|
|
||||||
const productCategorySchema = new mongoose.Schema(
|
|
||||||
{
|
|
||||||
_reference: { type: String, default: () => generateId()() },
|
|
||||||
name: { required: true, type: String },
|
|
||||||
},
|
|
||||||
{ timestamps: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
productCategorySchema.index({ name: 'text' });
|
|
||||||
|
|
||||||
productCategorySchema.virtual('id').get(function () {
|
|
||||||
return this._id;
|
|
||||||
});
|
|
||||||
|
|
||||||
productCategorySchema.set('toJSON', { virtuals: true });
|
|
||||||
|
|
||||||
export const productCategoryModel = mongoose.model('productCategory', productCategorySchema);
|
|
||||||
@ -1,63 +0,0 @@
|
|||||||
import mongoose from 'mongoose';
|
|
||||||
import { generateId } from '../../utils.js';
|
|
||||||
const { Schema } = mongoose;
|
|
||||||
|
|
||||||
const partSkuUsageSchema = new Schema({
|
|
||||||
part: { type: Schema.Types.ObjectId, ref: 'part', required: true },
|
|
||||||
partSku: { type: Schema.Types.ObjectId, ref: 'partSku', required: true },
|
|
||||||
quantity: { type: Number, required: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Define the main product SKU schema
|
|
||||||
const productSkuSchema = new Schema(
|
|
||||||
{
|
|
||||||
_reference: { type: String, default: () => generateId()() },
|
|
||||||
barcode: { type: String, required: false },
|
|
||||||
product: { type: Schema.Types.ObjectId, ref: 'product', required: true },
|
|
||||||
name: { type: String, required: true },
|
|
||||||
description: { type: String, required: false },
|
|
||||||
priceMode: { type: String, default: 'margin' },
|
|
||||||
price: { type: Number, required: false },
|
|
||||||
cost: { type: Number, required: false },
|
|
||||||
overrideCost: { type: Boolean, default: false },
|
|
||||||
overridePrice: { type: Boolean, default: false },
|
|
||||||
margin: { type: Number, required: false },
|
|
||||||
amount: { type: Number, required: false },
|
|
||||||
parts: [partSkuUsageSchema],
|
|
||||||
priceTaxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
|
|
||||||
costTaxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
|
|
||||||
priceWithTax: { type: Number, required: false },
|
|
||||||
costWithTax: { type: Number, required: false },
|
|
||||||
},
|
|
||||||
{ timestamps: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
productSkuSchema.index({ name: 'text', barcode: 'text', description: 'text' });
|
|
||||||
|
|
||||||
// Add virtual id getter
|
|
||||||
productSkuSchema.virtual('id').get(function () {
|
|
||||||
return this._id;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Configure JSON serialization to include virtuals
|
|
||||||
productSkuSchema.set('toJSON', { virtuals: true });
|
|
||||||
|
|
||||||
productSkuSchema.statics.recalculate = async function (productSku, user) {
|
|
||||||
const orderItemModel = mongoose.model('orderItem');
|
|
||||||
const skuId = productSku._id;
|
|
||||||
const draftOrderItems = await orderItemModel
|
|
||||||
.find({
|
|
||||||
'state.type': 'draft',
|
|
||||||
itemType: 'product',
|
|
||||||
sku: skuId,
|
|
||||||
})
|
|
||||||
.populate('order')
|
|
||||||
.lean();
|
|
||||||
|
|
||||||
for (const orderItem of draftOrderItems) {
|
|
||||||
await orderItemModel.recalculate(orderItem, user);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create and export the model
|
|
||||||
export const productSkuModel = mongoose.model('productSku', productSkuSchema);
|
|
||||||
@ -16,8 +16,6 @@ const taxRateSchema = new mongoose.Schema(
|
|||||||
{ timestamps: true }
|
{ timestamps: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
taxRateSchema.index({ name: 'text', description: 'text', country: 'text' });
|
|
||||||
|
|
||||||
taxRateSchema.virtual('id').get(function () {
|
taxRateSchema.virtual('id').get(function () {
|
||||||
return this._id;
|
return this._id;
|
||||||
});
|
});
|
||||||
@ -19,8 +19,6 @@ const taxRecordSchema = new Schema(
|
|||||||
{ timestamps: true }
|
{ timestamps: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
taxRecordSchema.index({ transactionType: 'text' });
|
|
||||||
|
|
||||||
taxRecordSchema.virtual('id').get(function () {
|
taxRecordSchema.virtual('id').get(function () {
|
||||||
return this._id;
|
return this._id;
|
||||||
});
|
});
|
||||||
@ -10,13 +10,10 @@ const userSchema = new mongoose.Schema(
|
|||||||
lastName: { required: false, type: String },
|
lastName: { required: false, type: String },
|
||||||
email: { required: true, type: String },
|
email: { required: true, type: String },
|
||||||
profileImage: { type: mongoose.SchemaTypes.ObjectId, ref: 'file', required: false },
|
profileImage: { type: mongoose.SchemaTypes.ObjectId, ref: 'file', required: false },
|
||||||
appPasswordHash: { type: String, required: false, select: false },
|
|
||||||
},
|
},
|
||||||
{ timestamps: true }
|
{ timestamps: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
userSchema.index({ username: 'text', name: 'text', firstName: 'text', lastName: 'text', email: 'text' });
|
|
||||||
|
|
||||||
userSchema.virtual('id').get(function () {
|
userSchema.virtual('id').get(function () {
|
||||||
return this._id;
|
return this._id;
|
||||||
});
|
});
|
||||||
|
|||||||
@ -26,8 +26,6 @@ const vendorSchema = new mongoose.Schema(
|
|||||||
{ timestamps: true }
|
{ timestamps: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
vendorSchema.index({ name: 'text', website: 'text', email: 'text', phone: 'text', contact: 'text', country: 'text' });
|
|
||||||
|
|
||||||
vendorSchema.virtual('id').get(function () {
|
vendorSchema.virtual('id').get(function () {
|
||||||
return this._id;
|
return this._id;
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,51 +0,0 @@
|
|||||||
import mongoose from 'mongoose';
|
|
||||||
import { generateId } from '../../utils.js';
|
|
||||||
const { Schema } = mongoose;
|
|
||||||
|
|
||||||
const notificationSchema = new mongoose.Schema({
|
|
||||||
_reference: { type: String, default: () => generateId()() },
|
|
||||||
user: {
|
|
||||||
type: Schema.Types.ObjectId,
|
|
||||||
ref: 'user',
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
message: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
type: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
default: 'info',
|
|
||||||
},
|
|
||||||
read: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
metadata: {
|
|
||||||
type: Object,
|
|
||||||
required: false,
|
|
||||||
},
|
|
||||||
createdAt: {
|
|
||||||
type: Date,
|
|
||||||
required: true,
|
|
||||||
default: Date.now,
|
|
||||||
},
|
|
||||||
updatedAt: {
|
|
||||||
type: Date,
|
|
||||||
required: true,
|
|
||||||
default: Date.now,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
notificationSchema.virtual('id').get(function () {
|
|
||||||
return this._id;
|
|
||||||
});
|
|
||||||
|
|
||||||
notificationSchema.set('toJSON', { virtuals: true });
|
|
||||||
|
|
||||||
export const notificationModel = mongoose.model('notification', notificationSchema);
|
|
||||||
@ -1,44 +0,0 @@
|
|||||||
import mongoose from 'mongoose';
|
|
||||||
import { generateId } from '../../utils.js';
|
|
||||||
const { Schema } = mongoose;
|
|
||||||
|
|
||||||
const userNotifierSchema = new mongoose.Schema({
|
|
||||||
_reference: { type: String, default: () => generateId()() },
|
|
||||||
user: {
|
|
||||||
type: Schema.Types.ObjectId,
|
|
||||||
ref: 'user',
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
email: {
|
|
||||||
type: Boolean,
|
|
||||||
required: true,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
object: {
|
|
||||||
type: Schema.Types.ObjectId,
|
|
||||||
refPath: 'objectType',
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
objectType: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
createdAt: {
|
|
||||||
type: Date,
|
|
||||||
required: true,
|
|
||||||
default: Date.now,
|
|
||||||
},
|
|
||||||
updatedAt: {
|
|
||||||
type: Date,
|
|
||||||
required: true,
|
|
||||||
default: Date.now,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
userNotifierSchema.virtual('id').get(function () {
|
|
||||||
return this._id;
|
|
||||||
});
|
|
||||||
|
|
||||||
userNotifierSchema.set('toJSON', { virtuals: true });
|
|
||||||
|
|
||||||
export const userNotifierModel = mongoose.model('userNotifier', userNotifierSchema);
|
|
||||||
@ -1,41 +0,0 @@
|
|||||||
import mongoose from 'mongoose';
|
|
||||||
import { generateId } from '../../utils.js';
|
|
||||||
const { Schema } = mongoose;
|
|
||||||
|
|
||||||
const userSettingsSchema = new mongoose.Schema({
|
|
||||||
_reference: { type: String, default: () => generateId()() },
|
|
||||||
user: {
|
|
||||||
type: Schema.Types.ObjectId,
|
|
||||||
ref: 'user',
|
|
||||||
required: true,
|
|
||||||
unique: true,
|
|
||||||
},
|
|
||||||
defaults: {
|
|
||||||
viewMode: { type: Schema.Types.Mixed, default: () => ({}) },
|
|
||||||
filterSidebarVisibility: {
|
|
||||||
type: Schema.Types.Mixed,
|
|
||||||
default: () => ({}),
|
|
||||||
},
|
|
||||||
columnVisibility: { type: Schema.Types.Mixed, default: () => ({}) },
|
|
||||||
collapseState: { type: Schema.Types.Mixed, default: () => ({}) },
|
|
||||||
},
|
|
||||||
|
|
||||||
createdAt: {
|
|
||||||
type: Date,
|
|
||||||
required: true,
|
|
||||||
default: Date.now,
|
|
||||||
},
|
|
||||||
updatedAt: {
|
|
||||||
type: Date,
|
|
||||||
required: true,
|
|
||||||
default: Date.now,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
userSettingsSchema.virtual('id').get(function () {
|
|
||||||
return this._id;
|
|
||||||
});
|
|
||||||
|
|
||||||
userSettingsSchema.set('toJSON', { virtuals: true });
|
|
||||||
|
|
||||||
export const userSettingsModel = mongoose.model('userSettings', userSettingsSchema);
|
|
||||||
@ -1,34 +1,21 @@
|
|||||||
import { jobModel } from './production/job.schema.js';
|
import { jobModel } from './production/job.schema.js';
|
||||||
import { subJobModel } from './production/subjob.schema.js';
|
import { subJobModel } from './production/subjob.schema.js';
|
||||||
import { printerModel } from './production/printer.schema.js';
|
import { printerModel } from './production/printer.schema.js';
|
||||||
import { printerProfileModel } from './production/printerprofile.schema.js';
|
|
||||||
import { filamentProfileModel } from './production/filamentprofile.schema.js';
|
|
||||||
import { filamentModel } from './management/filament.schema.js';
|
import { filamentModel } from './management/filament.schema.js';
|
||||||
import { filamentSkuModel } from './management/filamentsku.schema.js';
|
|
||||||
import { gcodeFileModel } from './production/gcodefile.schema.js';
|
import { gcodeFileModel } from './production/gcodefile.schema.js';
|
||||||
import { partModel } from './management/part.schema.js';
|
import { partModel } from './management/part.schema.js';
|
||||||
import { partSkuModel } from './management/partsku.schema.js';
|
|
||||||
import { productModel } from './management/product.schema.js';
|
import { productModel } from './management/product.schema.js';
|
||||||
import { productCategoryModel } from './management/productcategory.schema.js';
|
|
||||||
import { productSkuModel } from './management/productsku.schema.js';
|
|
||||||
import { vendorModel } from './management/vendor.schema.js';
|
import { vendorModel } from './management/vendor.schema.js';
|
||||||
import { materialModel } from './management/material.schema.js';
|
|
||||||
import { filamentStockModel } from './inventory/filamentstock.schema.js';
|
import { filamentStockModel } from './inventory/filamentstock.schema.js';
|
||||||
import { purchaseOrderModel } from './inventory/purchaseorder.schema.js';
|
import { purchaseOrderModel } from './inventory/purchaseorder.schema.js';
|
||||||
import { orderItemModel } from './inventory/orderitem.schema.js';
|
import { orderItemModel } from './inventory/orderitem.schema.js';
|
||||||
import { stockEventModel } from './inventory/stockevent.schema.js';
|
import { stockEventModel } from './inventory/stockevent.schema.js';
|
||||||
import { stockAuditModel } from './inventory/stockaudit.schema.js';
|
import { stockAuditModel } from './inventory/stockaudit.schema.js';
|
||||||
import { partStockModel } from './inventory/partstock.schema.js';
|
import { partStockModel } from './inventory/partstock.schema.js';
|
||||||
import { productStockModel } from './inventory/productstock.schema.js';
|
|
||||||
import { stockLocationModel } from './inventory/stocklocation.schema.js';
|
|
||||||
import { stockTransferModel } from './inventory/stocktransfer.schema.js';
|
|
||||||
import { auditLogModel } from './management/auditlog.schema.js';
|
import { auditLogModel } from './management/auditlog.schema.js';
|
||||||
import { userModel } from './management/user.schema.js';
|
import { userModel } from './management/user.schema.js';
|
||||||
import { appPasswordModel } from './management/apppassword.schema.js';
|
|
||||||
import { noteTypeModel } from './management/notetype.schema.js';
|
import { noteTypeModel } from './management/notetype.schema.js';
|
||||||
import { noteModel } from './misc/note.schema.js';
|
import { noteModel } from './misc/note.schema.js';
|
||||||
import { notificationModel } from './misc/notification.schema.js';
|
|
||||||
import { userNotifierModel } from './misc/usernotifier.schema.js';
|
|
||||||
import { documentSizeModel } from './management/documentsize.schema.js';
|
import { documentSizeModel } from './management/documentsize.schema.js';
|
||||||
import { documentTemplateModel } from './management/documenttemplate.schema.js';
|
import { documentTemplateModel } from './management/documenttemplate.schema.js';
|
||||||
import { hostModel } from './management/host.schema.js';
|
import { hostModel } from './management/host.schema.js';
|
||||||
@ -37,354 +24,78 @@ import { documentJobModel } from './management/documentjob.schema.js';
|
|||||||
import { fileModel } from './management/file.schema.js';
|
import { fileModel } from './management/file.schema.js';
|
||||||
import { courierServiceModel } from './management/courierservice.schema.js';
|
import { courierServiceModel } from './management/courierservice.schema.js';
|
||||||
import { courierModel } from './management/courier.schema.js';
|
import { courierModel } from './management/courier.schema.js';
|
||||||
import { taxRateModel } from './management/taxrate.schema.js';
|
import { taxRateModel } from './management/taxrates.schema.js';
|
||||||
import { taxRecordModel } from './finance/taxrecord.schema.js';
|
import { taxRecordModel } from './management/taxrecord.schema.js';
|
||||||
import { shipmentModel } from './inventory/shipment.schema.js';
|
|
||||||
import { invoiceModel } from './finance/invoice.schema.js';
|
|
||||||
import { clientModel } from './sales/client.schema.js';
|
|
||||||
import { salesOrderModel } from './sales/salesorder.schema.js';
|
|
||||||
import { marketplaceModel } from './sales/marketplace.schema.js';
|
|
||||||
import { listingModel } from './sales/listing.schema.js';
|
|
||||||
import { listingVarientModel } from './sales/listingvarient.schema.js';
|
|
||||||
import { paymentModel } from './finance/payment.schema.js';
|
|
||||||
|
|
||||||
// Map prefixes to models and id fields
|
// Map prefixes to models and id fields
|
||||||
export const models = {
|
export const models = {
|
||||||
PRN: {
|
PRN: { model: printerModel, idField: '_id', type: 'printer', referenceField: '_reference' },
|
||||||
model: printerModel,
|
FIL: { model: filamentModel, idField: '_id', type: 'filament', referenceField: '_reference' },
|
||||||
idField: '_id',
|
GCF: { model: gcodeFileModel, idField: '_id', type: 'gcodeFile', referenceField: '_reference' },
|
||||||
type: 'printer',
|
JOB: { model: jobModel, idField: '_id', type: 'job', referenceField: '_reference' },
|
||||||
referenceField: '_reference',
|
PRT: { model: partModel, idField: '_id', type: 'part', referenceField: '_reference' },
|
||||||
label: 'Printer',
|
PRD: { model: productModel, idField: '_id', type: 'product', referenceField: '_reference' },
|
||||||
},
|
VEN: { model: vendorModel, idField: '_id', type: 'vendor', referenceField: '_reference' },
|
||||||
PPF: {
|
SJB: { model: subJobModel, idField: '_id', type: 'subJob', referenceField: '_reference' },
|
||||||
model: printerProfileModel,
|
|
||||||
idField: '_id',
|
|
||||||
type: 'printerProfile',
|
|
||||||
referenceField: '_reference',
|
|
||||||
label: 'Printer Profile',
|
|
||||||
},
|
|
||||||
FPF: {
|
|
||||||
model: filamentProfileModel,
|
|
||||||
idField: '_id',
|
|
||||||
type: 'filamentProfile',
|
|
||||||
referenceField: '_reference',
|
|
||||||
label: 'Filament Profile',
|
|
||||||
},
|
|
||||||
FIL: {
|
|
||||||
model: filamentModel,
|
|
||||||
idField: '_id',
|
|
||||||
type: 'filament',
|
|
||||||
referenceField: '_reference',
|
|
||||||
label: 'Filament',
|
|
||||||
},
|
|
||||||
FSU: {
|
|
||||||
model: filamentSkuModel,
|
|
||||||
idField: '_id',
|
|
||||||
type: 'filamentSku',
|
|
||||||
referenceField: '_reference',
|
|
||||||
label: 'Filament SKU',
|
|
||||||
},
|
|
||||||
GCF: {
|
|
||||||
model: gcodeFileModel,
|
|
||||||
idField: '_id',
|
|
||||||
type: 'gcodeFile',
|
|
||||||
referenceField: '_reference',
|
|
||||||
label: 'G-Code File',
|
|
||||||
},
|
|
||||||
JOB: { model: jobModel, idField: '_id', type: 'job', referenceField: '_reference', label: 'Job' },
|
|
||||||
PRT: {
|
|
||||||
model: partModel,
|
|
||||||
idField: '_id',
|
|
||||||
type: 'part',
|
|
||||||
referenceField: '_reference',
|
|
||||||
label: 'Part',
|
|
||||||
},
|
|
||||||
PSU: {
|
|
||||||
model: partSkuModel,
|
|
||||||
idField: '_id',
|
|
||||||
type: 'partSku',
|
|
||||||
referenceField: '_reference',
|
|
||||||
label: 'Part SKU',
|
|
||||||
},
|
|
||||||
PRD: {
|
|
||||||
model: productModel,
|
|
||||||
idField: '_id',
|
|
||||||
type: 'product',
|
|
||||||
referenceField: '_reference',
|
|
||||||
label: 'Product',
|
|
||||||
},
|
|
||||||
PCG: {
|
|
||||||
model: productCategoryModel,
|
|
||||||
idField: '_id',
|
|
||||||
type: 'productCategory',
|
|
||||||
referenceField: '_reference',
|
|
||||||
label: 'Product Category',
|
|
||||||
},
|
|
||||||
SKU: {
|
|
||||||
model: productSkuModel,
|
|
||||||
idField: '_id',
|
|
||||||
type: 'productSku',
|
|
||||||
referenceField: '_reference',
|
|
||||||
label: 'Product SKU',
|
|
||||||
},
|
|
||||||
VEN: {
|
|
||||||
model: vendorModel,
|
|
||||||
idField: '_id',
|
|
||||||
type: 'vendor',
|
|
||||||
referenceField: '_reference',
|
|
||||||
label: 'Vendor',
|
|
||||||
},
|
|
||||||
MAT: {
|
|
||||||
model: materialModel,
|
|
||||||
idField: '_id',
|
|
||||||
type: 'material',
|
|
||||||
referenceField: '_reference',
|
|
||||||
label: 'Material',
|
|
||||||
},
|
|
||||||
SJB: {
|
|
||||||
model: subJobModel,
|
|
||||||
idField: '_id',
|
|
||||||
type: 'subJob',
|
|
||||||
referenceField: '_reference',
|
|
||||||
label: 'Sub Job',
|
|
||||||
},
|
|
||||||
FLS: {
|
FLS: {
|
||||||
model: filamentStockModel,
|
model: filamentStockModel,
|
||||||
idField: '_id',
|
idField: '_id',
|
||||||
type: 'filamentStock',
|
type: 'filamentStock',
|
||||||
referenceField: '_reference',
|
referenceField: '_reference',
|
||||||
label: 'Filament Stock',
|
|
||||||
},
|
|
||||||
SEV: {
|
|
||||||
model: stockEventModel,
|
|
||||||
idField: '_id',
|
|
||||||
type: 'stockEvent',
|
|
||||||
referenceField: '_reference',
|
|
||||||
label: 'Stock Event',
|
|
||||||
},
|
|
||||||
SAU: {
|
|
||||||
model: stockAuditModel,
|
|
||||||
idField: '_id',
|
|
||||||
type: 'stockAudit',
|
|
||||||
referenceField: '_reference',
|
|
||||||
label: 'Stock Audit',
|
|
||||||
},
|
|
||||||
PTS: {
|
|
||||||
model: partStockModel,
|
|
||||||
idField: '_id',
|
|
||||||
type: 'partStock',
|
|
||||||
referenceField: '_reference',
|
|
||||||
label: 'Part Stock',
|
|
||||||
},
|
|
||||||
PDS: {
|
|
||||||
model: productStockModel,
|
|
||||||
idField: '_id',
|
|
||||||
type: 'productStock',
|
|
||||||
referenceField: '_reference',
|
|
||||||
label: 'Product Stock',
|
|
||||||
},
|
|
||||||
SLN: {
|
|
||||||
model: stockLocationModel,
|
|
||||||
idField: '_id',
|
|
||||||
type: 'stockLocation',
|
|
||||||
referenceField: '_reference',
|
|
||||||
label: 'Stock Location',
|
|
||||||
},
|
|
||||||
STT: {
|
|
||||||
model: stockTransferModel,
|
|
||||||
idField: '_id',
|
|
||||||
type: 'stockTransfer',
|
|
||||||
referenceField: '_reference',
|
|
||||||
label: 'Stock Transfer',
|
|
||||||
},
|
|
||||||
ADL: {
|
|
||||||
model: auditLogModel,
|
|
||||||
idField: '_id',
|
|
||||||
type: 'auditLog',
|
|
||||||
referenceField: '_reference',
|
|
||||||
label: 'Audit Log',
|
|
||||||
},
|
|
||||||
USR: {
|
|
||||||
model: userModel,
|
|
||||||
idField: '_id',
|
|
||||||
type: 'user',
|
|
||||||
referenceField: '_reference',
|
|
||||||
label: 'User',
|
|
||||||
},
|
|
||||||
APP: {
|
|
||||||
model: appPasswordModel,
|
|
||||||
idField: '_id',
|
|
||||||
type: 'appPassword',
|
|
||||||
referenceField: '_reference',
|
|
||||||
label: 'App Password',
|
|
||||||
},
|
|
||||||
NTY: {
|
|
||||||
model: noteTypeModel,
|
|
||||||
idField: '_id',
|
|
||||||
type: 'noteType',
|
|
||||||
referenceField: '_reference',
|
|
||||||
label: 'Note Type',
|
|
||||||
},
|
|
||||||
NTE: {
|
|
||||||
model: noteModel,
|
|
||||||
idField: '_id',
|
|
||||||
type: 'note',
|
|
||||||
referenceField: '_reference',
|
|
||||||
label: 'Note',
|
|
||||||
},
|
|
||||||
NTF: {
|
|
||||||
model: notificationModel,
|
|
||||||
idField: '_id',
|
|
||||||
type: 'notification',
|
|
||||||
label: 'Notification',
|
|
||||||
referenceField: '_reference',
|
|
||||||
},
|
|
||||||
ONF: {
|
|
||||||
model: userNotifierModel,
|
|
||||||
idField: '_id',
|
|
||||||
type: 'userNotifier',
|
|
||||||
label: 'User Notifier',
|
|
||||||
referenceField: '_reference',
|
|
||||||
},
|
},
|
||||||
|
SEV: { model: stockEventModel, idField: '_id', type: 'stockEvent', referenceField: '_reference' },
|
||||||
|
SAU: { model: stockAuditModel, idField: '_id', type: 'stockAudit', referenceField: '_reference' },
|
||||||
|
PTS: { model: partStockModel, idField: '_id', type: 'partStock', referenceField: '_reference' },
|
||||||
|
PDS: { model: null, idField: '_id', type: 'productStock', referenceField: '_reference' }, // No productStockModel found
|
||||||
|
ADL: { model: auditLogModel, idField: '_id', type: 'auditLog', referenceField: '_reference' },
|
||||||
|
USR: { model: userModel, idField: '_id', type: 'user', referenceField: '_reference' },
|
||||||
|
NTY: { model: noteTypeModel, idField: '_id', type: 'noteType', referenceField: '_reference' },
|
||||||
|
NTE: { model: noteModel, idField: '_id', type: 'note', referenceField: '_reference' },
|
||||||
DSZ: {
|
DSZ: {
|
||||||
model: documentSizeModel,
|
model: documentSizeModel,
|
||||||
idField: '_id',
|
idField: '_id',
|
||||||
type: 'documentSize',
|
type: 'documentSize',
|
||||||
label: 'Document Size',
|
|
||||||
referenceField: '_reference',
|
referenceField: '_reference',
|
||||||
},
|
},
|
||||||
DTP: {
|
DTP: {
|
||||||
model: documentTemplateModel,
|
model: documentTemplateModel,
|
||||||
idField: '_id',
|
idField: '_id',
|
||||||
type: 'documentTemplate',
|
type: 'documentTemplate',
|
||||||
label: 'Document Template',
|
|
||||||
referenceField: '_reference',
|
referenceField: '_reference',
|
||||||
},
|
},
|
||||||
DPR: {
|
DPR: {
|
||||||
model: documentPrinterModel,
|
model: documentPrinterModel,
|
||||||
idField: '_id',
|
idField: '_id',
|
||||||
type: 'documentPrinter',
|
type: 'documentPrinter',
|
||||||
label: 'Document Printer',
|
|
||||||
referenceField: '_reference',
|
referenceField: '_reference',
|
||||||
},
|
},
|
||||||
DJB: {
|
DJB: {
|
||||||
model: documentJobModel,
|
model: documentJobModel,
|
||||||
idField: '_id',
|
idField: '_id',
|
||||||
type: 'documentJob',
|
type: 'documentJob',
|
||||||
label: 'Document Job',
|
|
||||||
referenceField: '_reference',
|
referenceField: '_reference',
|
||||||
},
|
},
|
||||||
HST: {
|
HST: { model: hostModel, idField: '_id', type: 'host', referenceField: '_reference' },
|
||||||
model: hostModel,
|
FLE: { model: fileModel, idField: '_id', type: 'file', referenceField: '_reference' },
|
||||||
idField: '_id',
|
|
||||||
type: 'host',
|
|
||||||
referenceField: '_reference',
|
|
||||||
label: 'Host',
|
|
||||||
},
|
|
||||||
FLE: {
|
|
||||||
model: fileModel,
|
|
||||||
idField: '_id',
|
|
||||||
type: 'file',
|
|
||||||
referenceField: '_reference',
|
|
||||||
label: 'File',
|
|
||||||
},
|
|
||||||
POR: {
|
POR: {
|
||||||
model: purchaseOrderModel,
|
model: purchaseOrderModel,
|
||||||
idField: '_id',
|
idField: '_id',
|
||||||
type: 'purchaseOrder',
|
type: 'purchaseOrder',
|
||||||
label: 'Purchase Order',
|
|
||||||
referenceField: '_reference',
|
referenceField: '_reference',
|
||||||
},
|
},
|
||||||
ODI: {
|
ODI: {
|
||||||
model: orderItemModel,
|
model: orderItemModel,
|
||||||
idField: '_id',
|
idField: '_id',
|
||||||
type: 'orderItem',
|
type: 'orderItem',
|
||||||
label: 'Order Item',
|
|
||||||
referenceField: '_reference',
|
referenceField: '_reference',
|
||||||
},
|
},
|
||||||
COS: {
|
COS: {
|
||||||
model: courierServiceModel,
|
model: courierServiceModel,
|
||||||
idField: '_id',
|
idField: '_id',
|
||||||
type: 'courierService',
|
type: 'courierService',
|
||||||
label: 'Courier Service',
|
|
||||||
referenceField: '_reference',
|
|
||||||
},
|
|
||||||
COR: {
|
|
||||||
model: courierModel,
|
|
||||||
idField: '_id',
|
|
||||||
type: 'courier',
|
|
||||||
label: 'Courier',
|
|
||||||
referenceField: '_reference',
|
|
||||||
},
|
|
||||||
TXR: {
|
|
||||||
model: taxRateModel,
|
|
||||||
idField: '_id',
|
|
||||||
type: 'taxRate',
|
|
||||||
label: 'Tax Rate',
|
|
||||||
referenceField: '_reference',
|
|
||||||
},
|
|
||||||
TXD: {
|
|
||||||
model: taxRecordModel,
|
|
||||||
idField: '_id',
|
|
||||||
type: 'taxRecord',
|
|
||||||
label: 'Tax Record',
|
|
||||||
referenceField: '_reference',
|
|
||||||
},
|
|
||||||
SHP: {
|
|
||||||
model: shipmentModel,
|
|
||||||
idField: '_id',
|
|
||||||
type: 'shipment',
|
|
||||||
label: 'Shipment',
|
|
||||||
referenceField: '_reference',
|
|
||||||
},
|
|
||||||
INV: {
|
|
||||||
model: invoiceModel,
|
|
||||||
idField: '_id',
|
|
||||||
type: 'invoice',
|
|
||||||
label: 'Invoice',
|
|
||||||
referenceField: '_reference',
|
|
||||||
},
|
|
||||||
CLI: {
|
|
||||||
model: clientModel,
|
|
||||||
idField: '_id',
|
|
||||||
type: 'client',
|
|
||||||
label: 'Client',
|
|
||||||
referenceField: '_reference',
|
|
||||||
},
|
|
||||||
SOR: {
|
|
||||||
model: salesOrderModel,
|
|
||||||
idField: '_id',
|
|
||||||
type: 'salesOrder',
|
|
||||||
label: 'Sales Order',
|
|
||||||
referenceField: '_reference',
|
|
||||||
},
|
|
||||||
MKT: {
|
|
||||||
model: marketplaceModel,
|
|
||||||
idField: '_id',
|
|
||||||
type: 'marketplace',
|
|
||||||
label: 'Marketplace',
|
|
||||||
referenceField: '_reference',
|
|
||||||
},
|
|
||||||
LST: {
|
|
||||||
model: listingModel,
|
|
||||||
idField: '_id',
|
|
||||||
type: 'listing',
|
|
||||||
label: 'Listing',
|
|
||||||
referenceField: '_reference',
|
|
||||||
},
|
|
||||||
LVR: {
|
|
||||||
model: listingVarientModel,
|
|
||||||
idField: '_id',
|
|
||||||
type: 'listingVarient',
|
|
||||||
label: 'Listing Varient',
|
|
||||||
referenceField: '_reference',
|
|
||||||
},
|
|
||||||
PAY: {
|
|
||||||
model: paymentModel,
|
|
||||||
idField: '_id',
|
|
||||||
type: 'payment',
|
|
||||||
label: 'Payment',
|
|
||||||
referenceField: '_reference',
|
referenceField: '_reference',
|
||||||
},
|
},
|
||||||
|
COR: { model: courierModel, idField: '_id', type: 'courier', referenceField: '_reference' },
|
||||||
|
TXR: { model: taxRateModel, idField: '_id', type: 'taxRate', referenceField: '_reference' },
|
||||||
|
TXD: { model: taxRecordModel, idField: '_id', type: 'taxRecord', referenceField: '_reference' },
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,165 +0,0 @@
|
|||||||
import mongoose from 'mongoose';
|
|
||||||
import { generateId } from '../../utils.js';
|
|
||||||
|
|
||||||
const filamentProfileSchema = new mongoose.Schema(
|
|
||||||
{
|
|
||||||
_reference: { type: String, default: () => generateId()() },
|
|
||||||
name: { type: String, required: true },
|
|
||||||
filamentType: { type: String, required: true },
|
|
||||||
filament: {
|
|
||||||
type: mongoose.Schema.Types.ObjectId,
|
|
||||||
refPath: 'filamentType',
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
filamentIsSupport: { type: Boolean },
|
|
||||||
filamentSoluble: { type: Boolean },
|
|
||||||
filamentPrintable: { type: Number },
|
|
||||||
filamentAdhesivenessCategory: { type: Number },
|
|
||||||
temperatureVitrification: { type: Number },
|
|
||||||
idleTemperature: { type: Number },
|
|
||||||
pelletFlowCoefficient: { type: Number },
|
|
||||||
requiredNozzleHrc: { type: Number },
|
|
||||||
filamentFlowRatio: { type: Number },
|
|
||||||
enablePressureAdvance: { type: Boolean },
|
|
||||||
pressureAdvance: { type: Number },
|
|
||||||
adaptivePressureAdvance: { type: Boolean },
|
|
||||||
adaptivePressureAdvanceBridges: { type: Boolean },
|
|
||||||
adaptivePressureAdvanceOverhangs: { type: Boolean },
|
|
||||||
adaptivePressureAdvanceModel: { type: String },
|
|
||||||
activateChamberTempControl: { type: Boolean },
|
|
||||||
chamberTemperature: { type: Number },
|
|
||||||
chamberMinimalTemperature: { type: Number },
|
|
||||||
nozzleTemperatureInitialLayer: { type: Number },
|
|
||||||
nozzleTemperature: { type: Number },
|
|
||||||
nozzleTemperatureRangeLow: { type: Number },
|
|
||||||
nozzleTemperatureRangeHigh: { type: Number },
|
|
||||||
hotPlateTempInitialLayer: { type: Number },
|
|
||||||
hotPlateTemp: { type: Number },
|
|
||||||
coolPlateTempInitialLayer: { type: Number },
|
|
||||||
coolPlateTemp: { type: Number },
|
|
||||||
engPlateTempInitialLayer: { type: Number },
|
|
||||||
engPlateTemp: { type: Number },
|
|
||||||
texturedPlateTempInitialLayer: { type: Number },
|
|
||||||
texturedPlateTemp: { type: Number },
|
|
||||||
texturedCoolPlateTempInitialLayer: { type: Number },
|
|
||||||
texturedCoolPlateTemp: { type: Number },
|
|
||||||
supertackPlateTempInitialLayer: { type: Number },
|
|
||||||
supertackPlateTemp: { type: Number },
|
|
||||||
filamentAdaptiveVolumetricSpeed: { type: Boolean },
|
|
||||||
filamentMaxVolumetricSpeed: { type: Number },
|
|
||||||
volumetricSpeedCoefficients: { type: String },
|
|
||||||
closeFanTheFirstXLayers: { type: Number },
|
|
||||||
fullFanSpeedLayer: { type: Number },
|
|
||||||
fanMinSpeed: { type: Number },
|
|
||||||
fanMaxSpeed: { type: Number },
|
|
||||||
reduceFanStopStartFreq: { type: Boolean },
|
|
||||||
slowDownForLayerCooling: { type: Boolean },
|
|
||||||
dontSlowDownOuterWall: { type: Boolean },
|
|
||||||
slowDownMinSpeed: { type: Number },
|
|
||||||
slowDownLayerTime: { type: Number },
|
|
||||||
fanCoolingLayerTime: { type: Number },
|
|
||||||
enableOverhangBridgeFan: { type: Boolean },
|
|
||||||
overhangFanThreshold: { type: String },
|
|
||||||
overhangFanSpeed: { type: Number },
|
|
||||||
internalBridgeFanSpeed: { type: Number },
|
|
||||||
supportMaterialInterfaceFanSpeed: { type: Number },
|
|
||||||
ironingFanSpeed: { type: Number },
|
|
||||||
initialLayerFanSpeed: { type: Number },
|
|
||||||
firstXLayerFanSpeed: { type: Number },
|
|
||||||
additionalCoolingFanSpeed: { type: Number },
|
|
||||||
additionalFanFullSpeedLayer: { type: Number },
|
|
||||||
closeAdditionalFanFirstXLayers: { type: Boolean },
|
|
||||||
activateAirFiltration: { type: Boolean },
|
|
||||||
activateAirFiltrationDuringPrint: { type: Boolean },
|
|
||||||
activateAirFiltrationOnCompletion: { type: Boolean },
|
|
||||||
duringPrintExhaustFanSpeed: { type: Number },
|
|
||||||
completePrintExhaustFanSpeed: { type: Number },
|
|
||||||
filamentRetractionLength: { type: Number },
|
|
||||||
filamentRetractionSpeed: { type: Number },
|
|
||||||
filamentDeretractionSpeed: { type: Number },
|
|
||||||
filamentRetractionMinimumTravel: { type: Number },
|
|
||||||
filamentRetractWhenChangingLayer: { type: Boolean },
|
|
||||||
filamentRetractBeforeWipe: { type: Number },
|
|
||||||
filamentRetractAfterWipe: { type: Number },
|
|
||||||
filamentRetractRestartExtra: { type: Number },
|
|
||||||
filamentRetractLiftAbove: { type: Number },
|
|
||||||
filamentRetractLiftBelow: { type: Number },
|
|
||||||
filamentRetractLiftEnforce: { type: String },
|
|
||||||
filamentWipe: { type: Boolean },
|
|
||||||
filamentWipeDistance: { type: Number },
|
|
||||||
filamentZHop: { type: Number },
|
|
||||||
filamentZHopTypes: { type: String },
|
|
||||||
filamentLongRetractionsWhenCut: { type: Boolean },
|
|
||||||
filamentRetractionDistancesWhenCut: { type: Number },
|
|
||||||
longRetractionsWhenEc: { type: Boolean },
|
|
||||||
retractionDistancesWhenEc: { type: Number },
|
|
||||||
filamentLoadingSpeed: { type: Number },
|
|
||||||
filamentLoadingSpeedStart: { type: Number },
|
|
||||||
filamentUnloadingSpeed: { type: Number },
|
|
||||||
filamentUnloadingSpeedStart: { type: Number },
|
|
||||||
filamentChangeLength: { type: Number },
|
|
||||||
filamentChangeLengthNc: { type: Number },
|
|
||||||
filamentToolchangeDelay: { type: Number },
|
|
||||||
filamentExtruderCompatibility: { type: Number },
|
|
||||||
filamentExtruderVariant: { type: String },
|
|
||||||
filamentMultitoolRamming: { type: Boolean },
|
|
||||||
filamentMultitoolRammingFlow: { type: Number },
|
|
||||||
filamentMultitoolRammingVolume: { type: Number },
|
|
||||||
filamentRammingParameters: { type: String },
|
|
||||||
filamentRammingTravelTime: { type: Number },
|
|
||||||
filamentRammingTravelTimeNc: { type: Number },
|
|
||||||
filamentRammingVolumetricSpeed: { type: Number },
|
|
||||||
filamentRammingVolumetricSpeedNc: { type: Number },
|
|
||||||
filamentMinimalPurgeOnWipeTower: { type: Number },
|
|
||||||
filamentTowerInterfacePreExtrusionDist: { type: Number },
|
|
||||||
filamentTowerInterfacePreExtrusionLength: { type: Number },
|
|
||||||
filamentTowerInterfacePrintTemp: { type: Number },
|
|
||||||
filamentTowerInterfacePurgeVolume: { type: Number },
|
|
||||||
filamentTowerIroningArea: { type: Number },
|
|
||||||
filamentCoolingBeforeTower: { type: Number },
|
|
||||||
filamentCoolingInitialSpeed: { type: Number },
|
|
||||||
filamentCoolingFinalSpeed: { type: Number },
|
|
||||||
filamentCoolingMoves: { type: Number },
|
|
||||||
filamentFlushTemp: { type: Number },
|
|
||||||
filamentFlushTempFast: { type: Number },
|
|
||||||
filamentFlushVolumetricSpeed: { type: Number },
|
|
||||||
filamentPreCoolingTemperature: { type: Number },
|
|
||||||
filamentPreCoolingTemperatureNc: { type: Number },
|
|
||||||
filamentPreheatTemperatureDelta: { type: Number },
|
|
||||||
filamentPrimeVolumeNc: { type: Number },
|
|
||||||
filamentRetractLengthNc: { type: Number },
|
|
||||||
filamentStampingDistance: { type: Number },
|
|
||||||
filamentStampingLoadingSpeed: { type: Number },
|
|
||||||
filamentStartGcode: { type: String },
|
|
||||||
filamentEndGcode: { type: String },
|
|
||||||
filamentChangeExtrusionRoleGcode: { type: String },
|
|
||||||
filamentShrink: { type: String },
|
|
||||||
filamentShrinkageCompensationZ: { type: String },
|
|
||||||
filamentDevAmsDryingTemperature: { type: Number },
|
|
||||||
filamentDevAmsDryingTime: { type: Number },
|
|
||||||
filamentDevAmsDryingHeatDistortionTemperature: { type: Number },
|
|
||||||
filamentDevAmsDryingAmsLimitations: { type: String },
|
|
||||||
filamentDevChamberDryingBedTemperature: { type: Number },
|
|
||||||
filamentDevChamberDryingTime: { type: Number },
|
|
||||||
filamentDevDryingCoolingTemperature: { type: Number },
|
|
||||||
filamentDevDryingSofteningTemperature: { type: Number },
|
|
||||||
compatiblePrinters: [
|
|
||||||
{ type: mongoose.Schema.Types.ObjectId, ref: 'printerProfile' },
|
|
||||||
],
|
|
||||||
compatiblePrintersCondition: { type: String },
|
|
||||||
compatiblePrints: [{ type: String }],
|
|
||||||
compatiblePrintsCondition: { type: String },
|
|
||||||
filamentNotes: { type: String },
|
|
||||||
},
|
|
||||||
{ timestamps: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
filamentProfileSchema.index({ name: 'text', filamentNotes: 'text' });
|
|
||||||
|
|
||||||
filamentProfileSchema.virtual('id').get(function () {
|
|
||||||
return this._id;
|
|
||||||
});
|
|
||||||
|
|
||||||
filamentProfileSchema.set('toJSON', { virtuals: true });
|
|
||||||
|
|
||||||
export const filamentProfileModel = mongoose.model('filamentProfile', filamentProfileSchema);
|
|
||||||
@ -4,7 +4,6 @@ const { Schema } = mongoose;
|
|||||||
|
|
||||||
const partSchema = new mongoose.Schema({
|
const partSchema = new mongoose.Schema({
|
||||||
part: { type: Schema.Types.ObjectId, ref: 'part', required: true },
|
part: { type: Schema.Types.ObjectId, ref: 'part', required: true },
|
||||||
partSku: { type: Schema.Types.ObjectId, ref: 'partSku', required: true },
|
|
||||||
quantity: { type: Number, required: true },
|
quantity: { type: Number, required: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -15,7 +14,6 @@ const gcodeFileSchema = new mongoose.Schema(
|
|||||||
gcodeFileName: { required: false, type: String },
|
gcodeFileName: { required: false, type: String },
|
||||||
size: { type: Number, required: false },
|
size: { type: Number, required: false },
|
||||||
filament: { type: Schema.Types.ObjectId, ref: 'filament', required: true },
|
filament: { type: Schema.Types.ObjectId, ref: 'filament', required: true },
|
||||||
filamentSku: { type: Schema.Types.ObjectId, ref: 'filamentSku', required: true },
|
|
||||||
parts: [partSchema],
|
parts: [partSchema],
|
||||||
file: { type: mongoose.SchemaTypes.ObjectId, ref: 'file', required: false },
|
file: { type: mongoose.SchemaTypes.ObjectId, ref: 'file', required: false },
|
||||||
cost: { type: Number, required: false },
|
cost: { type: Number, required: false },
|
||||||
@ -23,22 +21,7 @@ const gcodeFileSchema = new mongoose.Schema(
|
|||||||
{ timestamps: true }
|
{ timestamps: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
gcodeFileSchema.pre('validate', async function () {
|
gcodeFileSchema.index({ name: 'text', brand: 'text' });
|
||||||
if (!this.filament && this.filamentSku) {
|
|
||||||
const sku = await mongoose.model('filamentSku').findById(this.filamentSku).select('filament').lean();
|
|
||||||
if (sku?.filament) this.filament = sku.filament;
|
|
||||||
}
|
|
||||||
if (this.parts?.length) {
|
|
||||||
for (const partItem of this.parts) {
|
|
||||||
if (!partItem.part && partItem.partSku) {
|
|
||||||
const sku = await mongoose.model('partSku').findById(partItem.partSku).select('part').lean();
|
|
||||||
if (sku?.part) partItem.part = sku.part;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
gcodeFileSchema.index({ name: 'text', gcodeFileName: 'text' });
|
|
||||||
|
|
||||||
gcodeFileSchema.virtual('id').get(function () {
|
gcodeFileSchema.virtual('id').get(function () {
|
||||||
return this._id;
|
return this._id;
|
||||||
|
|||||||
@ -1,15 +1,7 @@
|
|||||||
import mongoose from 'mongoose';
|
import mongoose from 'mongoose';
|
||||||
import { generateId } from '../../utils.js';
|
import { generateId } from '../../utils.js';
|
||||||
const { Schema } = mongoose;
|
const { Schema } = mongoose;
|
||||||
import {
|
import { aggregateRollups, aggregateRollupsHistory } from '../../database.js';
|
||||||
aggregateRollups,
|
|
||||||
aggregateRollupsHistory,
|
|
||||||
listObjects,
|
|
||||||
editObject,
|
|
||||||
newObject,
|
|
||||||
deleteObject,
|
|
||||||
} from '../../database.js';
|
|
||||||
import { subJobModel } from './subjob.schema.js';
|
|
||||||
|
|
||||||
const jobSchema = new mongoose.Schema(
|
const jobSchema = new mongoose.Schema(
|
||||||
{
|
{
|
||||||
@ -40,8 +32,6 @@ const jobSchema = new mongoose.Schema(
|
|||||||
{ timestamps: true }
|
{ timestamps: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
jobSchema.index({ 'state.type': 'text' });
|
|
||||||
|
|
||||||
const rollupConfigs = [
|
const rollupConfigs = [
|
||||||
{
|
{
|
||||||
name: 'queued',
|
name: 'queued',
|
||||||
@ -92,95 +82,6 @@ jobSchema.statics.history = async function (from, to) {
|
|||||||
return results;
|
return results;
|
||||||
};
|
};
|
||||||
|
|
||||||
const normalizeId = (value) => value?._id ?? value;
|
|
||||||
|
|
||||||
jobSchema.statics.recalculate = async function (job, user) {
|
|
||||||
const jobId = job._id || job;
|
|
||||||
if (!jobId || job?.state?.type !== 'draft') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const quantity = job.quantity || 1;
|
|
||||||
const printers = (job.printers || []).map((printer) => normalizeId(printer));
|
|
||||||
const gcodeFile = normalizeId(job.gcodeFile);
|
|
||||||
|
|
||||||
const existingSubJobs = await listObjects({
|
|
||||||
model: subJobModel,
|
|
||||||
filter: { job: jobId },
|
|
||||||
sort: 'number',
|
|
||||||
order: 'ascend',
|
|
||||||
pagination: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existingSubJobs?.error) {
|
|
||||||
throw existingSubJobs;
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingSubJobsByNumber = new Map(
|
|
||||||
existingSubJobs.map((subJob) => [subJob.number, subJob])
|
|
||||||
);
|
|
||||||
|
|
||||||
var printerCount = 0;
|
|
||||||
|
|
||||||
for (let i = 0; i < quantity; i++) {
|
|
||||||
const subJobNumber = i + 1;
|
|
||||||
const printer = printers[printerCount];
|
|
||||||
const subJobUpdateData = {
|
|
||||||
updatedAt: new Date(),
|
|
||||||
printer,
|
|
||||||
gcodeFile,
|
|
||||||
number: subJobNumber,
|
|
||||||
};
|
|
||||||
|
|
||||||
const existingSubJob = existingSubJobsByNumber.get(subJobNumber);
|
|
||||||
|
|
||||||
if (existingSubJob) {
|
|
||||||
const subJobResult = await editObject({
|
|
||||||
model: subJobModel,
|
|
||||||
id: existingSubJob._id,
|
|
||||||
updateData: subJobUpdateData,
|
|
||||||
user,
|
|
||||||
});
|
|
||||||
if (subJobResult.error) {
|
|
||||||
throw subJobResult;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const subJobResult = await newObject({
|
|
||||||
model: subJobModel,
|
|
||||||
newData: {
|
|
||||||
createdAt: new Date(),
|
|
||||||
...subJobUpdateData,
|
|
||||||
job: jobId,
|
|
||||||
state: { type: 'draft' },
|
|
||||||
},
|
|
||||||
user,
|
|
||||||
});
|
|
||||||
if (subJobResult.error) {
|
|
||||||
throw subJobResult;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (printerCount >= printers.length - 1) {
|
|
||||||
printerCount = 0;
|
|
||||||
} else {
|
|
||||||
printerCount += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const existingSubJob of existingSubJobs) {
|
|
||||||
if (existingSubJob.number > quantity) {
|
|
||||||
const deleteResult = await deleteObject({
|
|
||||||
model: subJobModel,
|
|
||||||
id: existingSubJob._id,
|
|
||||||
user,
|
|
||||||
});
|
|
||||||
if (deleteResult.error) {
|
|
||||||
throw deleteResult;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
jobSchema.virtual('id').get(function () {
|
jobSchema.virtual('id').get(function () {
|
||||||
return this._id;
|
return this._id;
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import mongoose from 'mongoose';
|
import mongoose from 'mongoose';
|
||||||
import { generateId } from '../../utils.js';
|
import { generateId } from '../../utils.js';
|
||||||
const { Schema } = mongoose;
|
const { Schema } = mongoose;
|
||||||
import { aggregateRollups, aggregateRollupsHistory, editObject } from '../../database.js';
|
import { aggregateRollups, aggregateRollupsHistory } from '../../database.js';
|
||||||
|
|
||||||
// Define the moonraker connection schema
|
// Define the moonraker connection schema
|
||||||
const moonrakerSchema = new Schema(
|
const moonrakerSchema = new Schema(
|
||||||
@ -19,25 +19,12 @@ const alertSchema = new Schema(
|
|||||||
type: { type: String, required: true }, // error, info, message
|
type: { type: String, required: true }, // error, info, message
|
||||||
message: { type: String, required: false },
|
message: { type: String, required: false },
|
||||||
actions: [{ type: String, required: false, default: [] }],
|
actions: [{ type: String, required: false, default: [] }],
|
||||||
_id: { type: String, required: true, default: () => generateId()() },
|
_id: { type: String, required: true },
|
||||||
code: { type: String, required: false },
|
|
||||||
canDismiss: { type: Boolean, required: true, default: true },
|
canDismiss: { type: Boolean, required: true, default: true },
|
||||||
},
|
},
|
||||||
{ timestamps: true, _id: false }
|
{ timestamps: true, _id: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
const pendingSlicerUploadSchema = new Schema(
|
|
||||||
{
|
|
||||||
file: { type: Schema.Types.ObjectId, ref: 'file', required: true },
|
|
||||||
gcodeFile: { type: Schema.Types.ObjectId, ref: 'gcodeFile', default: null },
|
|
||||||
job: { type: Schema.Types.ObjectId, ref: 'job', default: null },
|
|
||||||
shouldPrint: { type: Boolean, required: true, default: false },
|
|
||||||
new: { type: Boolean, required: true, default: true },
|
|
||||||
properties: { type: Object, required: true, default: () => ({}) },
|
|
||||||
},
|
|
||||||
{ timestamps: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
// Define the main printer schema
|
// Define the main printer schema
|
||||||
const printerSchema = new Schema(
|
const printerSchema = new Schema(
|
||||||
{
|
{
|
||||||
@ -66,13 +53,10 @@ const printerSchema = new Schema(
|
|||||||
vendor: { type: Schema.Types.ObjectId, ref: 'vendor', default: null },
|
vendor: { type: Schema.Types.ObjectId, ref: 'vendor', default: null },
|
||||||
host: { type: Schema.Types.ObjectId, ref: 'host', default: null },
|
host: { type: Schema.Types.ObjectId, ref: 'host', default: null },
|
||||||
alerts: [alertSchema],
|
alerts: [alertSchema],
|
||||||
pendingSlicerUploads: { type: [pendingSlicerUploadSchema], default: [] },
|
|
||||||
},
|
},
|
||||||
{ timestamps: true }
|
{ timestamps: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
printerSchema.index({ name: 'text', tags: 'text', firmware: 'text' });
|
|
||||||
|
|
||||||
const rollupConfigs = [
|
const rollupConfigs = [
|
||||||
{
|
{
|
||||||
name: 'standby',
|
name: 'standby',
|
||||||
@ -108,6 +92,8 @@ printerSchema.statics.stats = async function () {
|
|||||||
rollupConfigs: rollupConfigs,
|
rollupConfigs: rollupConfigs,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log(results);
|
||||||
|
|
||||||
// Transform the results to match the expected format
|
// Transform the results to match the expected format
|
||||||
return results;
|
return results;
|
||||||
};
|
};
|
||||||
@ -124,28 +110,6 @@ printerSchema.statics.history = async function (from, to) {
|
|||||||
return results;
|
return results;
|
||||||
};
|
};
|
||||||
|
|
||||||
printerSchema.statics.recalculate = async function (printer, user) {
|
|
||||||
if (printer.active === false && printer.state?.type !== 'inactive') {
|
|
||||||
await editObject({
|
|
||||||
model: this,
|
|
||||||
id: printer._id,
|
|
||||||
updateData: { state: { type: 'inactive' } },
|
|
||||||
user,
|
|
||||||
recalculate: false,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (printer.active === true && printer.state?.type === 'inactive' && printer.online === false) {
|
|
||||||
await editObject({
|
|
||||||
model: this,
|
|
||||||
id: printer._id,
|
|
||||||
updateData: { state: { type: 'offline' } },
|
|
||||||
user,
|
|
||||||
recalculate: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add virtual id getter
|
// Add virtual id getter
|
||||||
printerSchema.virtual('id').get(function () {
|
printerSchema.virtual('id').get(function () {
|
||||||
return this._id;
|
return this._id;
|
||||||
|
|||||||
@ -1,111 +0,0 @@
|
|||||||
import mongoose from 'mongoose';
|
|
||||||
import { generateId } from '../../utils.js';
|
|
||||||
|
|
||||||
const minMaxNumberSchema = {
|
|
||||||
min: { type: Number },
|
|
||||||
max: { type: Number }
|
|
||||||
};
|
|
||||||
|
|
||||||
const thumbnailSchema = new mongoose.Schema(
|
|
||||||
{
|
|
||||||
width: { type: Number },
|
|
||||||
height: { type: Number }
|
|
||||||
},
|
|
||||||
{ _id: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
const bedExcludeAreaSchema = new mongoose.Schema(
|
|
||||||
{
|
|
||||||
x: { type: Number },
|
|
||||||
y: { type: Number }
|
|
||||||
},
|
|
||||||
{ _id: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
const extruderSchema = new mongoose.Schema(
|
|
||||||
{
|
|
||||||
defaultNozzleVolumeType: { type: String },
|
|
||||||
nozzleDiameter: { type: Number },
|
|
||||||
nozzleVolume: { type: Number },
|
|
||||||
nozzleType: { type: String },
|
|
||||||
nozzleFlushDataset: { type: String },
|
|
||||||
minLayerHeight: { type: Number },
|
|
||||||
maxLayerHeight: { type: Number },
|
|
||||||
extruderColour: { type: String },
|
|
||||||
extruderType: { type: String },
|
|
||||||
extruderVariantList: { type: String },
|
|
||||||
extruderPrintableHeight: { type: Number },
|
|
||||||
printerExtruderId: { type: String },
|
|
||||||
printerExtruderVariant: { type: String },
|
|
||||||
positionOffsetX: { type: Number },
|
|
||||||
positionOffsetY: { type: Number },
|
|
||||||
retractionLength: { type: Number },
|
|
||||||
retractionExtraLengthOnRestart: { type: Number },
|
|
||||||
retractionSpeed: { type: Number },
|
|
||||||
deretractionSpeed: { type: Number },
|
|
||||||
retractionTravelDistanceThreshold: { type: Number },
|
|
||||||
retractOnLayerChange: { type: Boolean },
|
|
||||||
wipeWhileRetracting: { type: Boolean },
|
|
||||||
wipeDistance: { type: Number },
|
|
||||||
retractAmountBeforeWipe: { type: Number },
|
|
||||||
retractAmountAfterWipe: { type: Number },
|
|
||||||
zHopOnSurfaces: { type: String },
|
|
||||||
zHopType: { type: String },
|
|
||||||
zHopHeight: { type: Number },
|
|
||||||
zHopTravelingAngle: { type: Number },
|
|
||||||
zHopOnlyLiftZAbove: { type: Number },
|
|
||||||
zHopOnlyLiftZBelow: { type: Number },
|
|
||||||
materialSwitchRetractionLength: { type: Number },
|
|
||||||
materialSwitchExtraLengthOnRestart: { type: Number },
|
|
||||||
longRetractionWhenCut: { type: Boolean },
|
|
||||||
retractionDistancesWhenCut: { type: Number },
|
|
||||||
},
|
|
||||||
{ _id: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
const printerProfileSchema = new mongoose.Schema(
|
|
||||||
{
|
|
||||||
_reference: { type: String, default: () => generateId()() },
|
|
||||||
name: { type: String, required: true },
|
|
||||||
printer: { type: mongoose.Schema.Types.ObjectId, ref: 'printer', default: null },
|
|
||||||
extruders: [extruderSchema],
|
|
||||||
printBedWidth: { type: Number },
|
|
||||||
printBedHeight: { type: Number },
|
|
||||||
originX: { type: Number },
|
|
||||||
originY: { type: Number },
|
|
||||||
bedExcludeArea: [bedExcludeAreaSchema],
|
|
||||||
printableHeight: { type: Number },
|
|
||||||
supportMultiBedTypes: { type: Boolean },
|
|
||||||
bestObjectPositionX: { type: Number },
|
|
||||||
bestObjectPositionY: { type: Number },
|
|
||||||
zOffset: { type: Number },
|
|
||||||
preferredOrientation: { type: Number },
|
|
||||||
machineMaxAccelerationRetracting: minMaxNumberSchema,
|
|
||||||
machineMaxSpeedE: minMaxNumberSchema,
|
|
||||||
machineMaxSpeedX: minMaxNumberSchema,
|
|
||||||
machineMaxSpeedY: minMaxNumberSchema,
|
|
||||||
machinePauseGcode: { type: String },
|
|
||||||
machineStartGcode: { type: String },
|
|
||||||
machineEndGcode: { type: String },
|
|
||||||
layerChangeGcode: { type: String },
|
|
||||||
beforeLayerChangeGcode: { type: String },
|
|
||||||
printerNotes: { type: String },
|
|
||||||
scanFirstLayer: { type: Boolean },
|
|
||||||
machineLoadFilamentTime: { type: Number },
|
|
||||||
machineUnloadFilamentTime: { type: Number },
|
|
||||||
thumbnails: [thumbnailSchema],
|
|
||||||
auxiliaryFan: { type: Boolean },
|
|
||||||
machineMaxJunctionDeviation: minMaxNumberSchema,
|
|
||||||
},
|
|
||||||
{ timestamps: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
printerProfileSchema.index({ name: 'text', printerNotes: 'text' });
|
|
||||||
|
|
||||||
printerProfileSchema.virtual('id').get(function () {
|
|
||||||
return this._id;
|
|
||||||
});
|
|
||||||
|
|
||||||
printerProfileSchema.set('toJSON', { virtuals: true });
|
|
||||||
|
|
||||||
export const printerProfileModel = mongoose.model('printerProfile', printerProfileSchema);
|
|
||||||
@ -43,8 +43,6 @@ const subJobSchema = new mongoose.Schema({
|
|||||||
finishedAt: { required: false, type: Date, default: null },
|
finishedAt: { required: false, type: Date, default: null },
|
||||||
});
|
});
|
||||||
|
|
||||||
subJobSchema.index({ moonrakerJobId: 'text', 'state.type': 'text' });
|
|
||||||
|
|
||||||
subJobSchema.virtual('id').get(function () {
|
subJobSchema.virtual('id').get(function () {
|
||||||
return this._id;
|
return this._id;
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,37 +0,0 @@
|
|||||||
import mongoose from 'mongoose';
|
|
||||||
import { generateId } from '../../utils.js';
|
|
||||||
|
|
||||||
const addressSchema = new mongoose.Schema({
|
|
||||||
building: { required: false, type: String },
|
|
||||||
addressLine1: { required: false, type: String },
|
|
||||||
addressLine2: { required: false, type: String },
|
|
||||||
city: { required: false, type: String },
|
|
||||||
state: { required: false, type: String },
|
|
||||||
postcode: { required: false, type: String },
|
|
||||||
country: { required: false, type: String },
|
|
||||||
});
|
|
||||||
|
|
||||||
const clientSchema = new mongoose.Schema(
|
|
||||||
{
|
|
||||||
_reference: { type: String, default: () => generateId()() },
|
|
||||||
name: { required: true, type: String },
|
|
||||||
marketplace: { type: mongoose.Schema.Types.ObjectId, ref: 'marketplace', required: false },
|
|
||||||
email: { required: false, type: String },
|
|
||||||
phone: { required: false, type: String },
|
|
||||||
country: { required: false, type: String },
|
|
||||||
active: { required: true, type: Boolean, default: true },
|
|
||||||
address: { required: false, type: addressSchema },
|
|
||||||
tags: [{ required: false, type: String }],
|
|
||||||
},
|
|
||||||
{ timestamps: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
clientSchema.index({ name: 'text', email: 'text', phone: 'text', country: 'text', tags: 'text' });
|
|
||||||
|
|
||||||
clientSchema.virtual('id').get(function () {
|
|
||||||
return this._id;
|
|
||||||
});
|
|
||||||
|
|
||||||
clientSchema.set('toJSON', { virtuals: true });
|
|
||||||
|
|
||||||
export const clientModel = mongoose.model('client', clientSchema);
|
|
||||||
@ -1,47 +0,0 @@
|
|||||||
import mongoose from 'mongoose';
|
|
||||||
import { generateId } from '../../utils.js';
|
|
||||||
const { Schema } = mongoose;
|
|
||||||
|
|
||||||
const listingSchema = new Schema(
|
|
||||||
{
|
|
||||||
_reference: { type: String, default: () => generateId()() },
|
|
||||||
product: { type: Schema.Types.ObjectId, ref: 'product', required: false },
|
|
||||||
vendor: { type: Schema.Types.ObjectId, ref: 'vendor', required: true },
|
|
||||||
stockLocation: { type: Schema.Types.ObjectId, ref: 'stockLocation', required: true },
|
|
||||||
marketplace: { type: Schema.Types.ObjectId, ref: 'marketplace', required: true },
|
|
||||||
title: { type: String, required: false },
|
|
||||||
state: {
|
|
||||||
type: {
|
|
||||||
type: String,
|
|
||||||
enum: ['draft', 'active', 'inactive', 'deleted', 'suspended', 'syncing'],
|
|
||||||
default: 'draft',
|
|
||||||
},
|
|
||||||
message: { type: String, required: false },
|
|
||||||
},
|
|
||||||
url: { type: String, required: false },
|
|
||||||
price: { type: Number, required: false },
|
|
||||||
currency: { type: String, required: false },
|
|
||||||
lastSyncedAt: { type: Date, required: false },
|
|
||||||
courierServices: [{ type: Schema.Types.ObjectId, ref: 'courierService', required: true }],
|
|
||||||
},
|
|
||||||
{ timestamps: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
listingSchema.index({ title: 'text', url: 'text' });
|
|
||||||
|
|
||||||
listingSchema.virtual('id').get(function () {
|
|
||||||
return this._id;
|
|
||||||
});
|
|
||||||
|
|
||||||
listingSchema.set('toJSON', {
|
|
||||||
virtuals: true,
|
|
||||||
transform(doc, ret) {
|
|
||||||
if (!ret.state && ret.status) {
|
|
||||||
ret.state = { type: ret.status, message: null };
|
|
||||||
}
|
|
||||||
if (ret.status) delete ret.status;
|
|
||||||
return ret;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const listingModel = mongoose.model('listing', listingSchema);
|
|
||||||
@ -1,45 +0,0 @@
|
|||||||
import mongoose from 'mongoose';
|
|
||||||
import { generateId } from '../../utils.js';
|
|
||||||
const { Schema } = mongoose;
|
|
||||||
|
|
||||||
const listingVarientSchema = new Schema(
|
|
||||||
{
|
|
||||||
_reference: { type: String, default: () => generateId()() },
|
|
||||||
listing: { type: Schema.Types.ObjectId, ref: 'listing', required: true },
|
|
||||||
product: { type: Schema.Types.ObjectId, ref: 'product', required: false },
|
|
||||||
productSku: { type: Schema.Types.ObjectId, ref: 'productSku', required: false },
|
|
||||||
state: {
|
|
||||||
type: {
|
|
||||||
type: String,
|
|
||||||
enum: ['draft', 'active', 'inactive', 'deleted', 'suspended', 'syncing'],
|
|
||||||
default: 'draft',
|
|
||||||
},
|
|
||||||
message: { type: String, required: false },
|
|
||||||
},
|
|
||||||
price: { type: Number, required: false },
|
|
||||||
currency: { type: String, required: false },
|
|
||||||
priceTaxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
|
|
||||||
priceWithTax: { type: Number, required: false },
|
|
||||||
lastSyncedAt: { type: Date, required: false },
|
|
||||||
},
|
|
||||||
{ timestamps: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
listingVarientSchema.index({ currency: 'text', 'state.type': 'text' });
|
|
||||||
|
|
||||||
listingVarientSchema.virtual('id').get(function () {
|
|
||||||
return this._id;
|
|
||||||
});
|
|
||||||
|
|
||||||
listingVarientSchema.set('toJSON', {
|
|
||||||
virtuals: true,
|
|
||||||
transform(doc, ret) {
|
|
||||||
if (!ret.state && ret.status) {
|
|
||||||
ret.state = { type: ret.status, message: null };
|
|
||||||
}
|
|
||||||
if (ret.status) delete ret.status;
|
|
||||||
return ret;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const listingVarientModel = mongoose.model('listingVarient', listingVarientSchema);
|
|
||||||
@ -1,58 +0,0 @@
|
|||||||
import mongoose from 'mongoose';
|
|
||||||
import { editObject } from '../../database.js';
|
|
||||||
import { generateId } from '../../utils.js';
|
|
||||||
|
|
||||||
const marketplaceSchema = new mongoose.Schema(
|
|
||||||
{
|
|
||||||
_reference: { type: String, default: () => generateId()() },
|
|
||||||
name: { required: true, type: String },
|
|
||||||
provider: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
enum: ['ebay', 'etsy', 'tiktokShop'],
|
|
||||||
},
|
|
||||||
active: { required: true, type: Boolean, default: true },
|
|
||||||
connected: { type: Boolean, required: true, default: false },
|
|
||||||
connectedAt: { type: Date, required: false },
|
|
||||||
state: {
|
|
||||||
type: {
|
|
||||||
type: String,
|
|
||||||
enum: ['active', 'inactive', 'suspended', 'ready', 'offline', 'syncing'],
|
|
||||||
default: 'offline',
|
|
||||||
},
|
|
||||||
message: { type: String, required: false },
|
|
||||||
},
|
|
||||||
// Provider-specific API configuration (flexible for eBay, Etsy, TikTok Shop)
|
|
||||||
config: { type: mongoose.Schema.Types.Mixed, default: {} },
|
|
||||||
},
|
|
||||||
{ timestamps: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
marketplaceSchema.index({ name: 'text', provider: 'text' });
|
|
||||||
|
|
||||||
marketplaceSchema.virtual('id').get(function () {
|
|
||||||
return this._id;
|
|
||||||
});
|
|
||||||
|
|
||||||
marketplaceSchema.statics.recalculate = async function (marketplace, user) {
|
|
||||||
let stateType;
|
|
||||||
if (marketplace.active === false) {
|
|
||||||
stateType = 'inactive';
|
|
||||||
} else if (marketplace.connected === false) {
|
|
||||||
stateType = 'disconnected';
|
|
||||||
} else {
|
|
||||||
stateType = 'ready';
|
|
||||||
}
|
|
||||||
marketplace.state = { type: stateType };
|
|
||||||
await editObject({
|
|
||||||
model: this,
|
|
||||||
id: marketplace._id,
|
|
||||||
updateData: { state: { type: stateType } },
|
|
||||||
user,
|
|
||||||
recalculate: false,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
marketplaceSchema.set('toJSON', { virtuals: true });
|
|
||||||
|
|
||||||
export const marketplaceModel = mongoose.model('marketplace', marketplaceSchema);
|
|
||||||
@ -1,217 +0,0 @@
|
|||||||
import mongoose from 'mongoose';
|
|
||||||
import { generateId } from '../../utils.js';
|
|
||||||
const { Schema } = mongoose;
|
|
||||||
import {
|
|
||||||
aggregateRollups,
|
|
||||||
aggregateRollupsHistory,
|
|
||||||
editObject,
|
|
||||||
} from '../../database.js';
|
|
||||||
|
|
||||||
const salesOrderSchema = new Schema(
|
|
||||||
{
|
|
||||||
_reference: { type: String, default: () => generateId()() },
|
|
||||||
totalAmount: { type: Number, required: true, default: 0 },
|
|
||||||
totalAmountWithTax: { type: Number, required: true, default: 0 },
|
|
||||||
shippingAmount: { type: Number, required: true, default: 0 },
|
|
||||||
shippingAmountWithTax: { type: Number, required: true, default: 0 },
|
|
||||||
grandTotalAmount: { type: Number, required: true, default: 0 },
|
|
||||||
totalTaxAmount: { type: Number, required: true, default: 0 },
|
|
||||||
timestamp: { type: Date, default: Date.now },
|
|
||||||
client: { type: Schema.Types.ObjectId, ref: 'client', required: true },
|
|
||||||
marketplace: { type: Schema.Types.ObjectId, ref: 'marketplace', required: false },
|
|
||||||
state: {
|
|
||||||
type: { type: String, required: true, default: 'draft' },
|
|
||||||
},
|
|
||||||
postedAt: { type: Date, required: false },
|
|
||||||
confirmedAt: { type: Date, required: false },
|
|
||||||
cancelledAt: { type: Date, required: false },
|
|
||||||
completedAt: { type: Date, required: false },
|
|
||||||
},
|
|
||||||
{ timestamps: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
salesOrderSchema.index({ 'state.type': 'text' });
|
|
||||||
|
|
||||||
const rollupConfigs = [
|
|
||||||
{
|
|
||||||
name: 'draft',
|
|
||||||
filter: { 'state.type': 'draft' },
|
|
||||||
rollups: [{ name: 'draft', property: 'state.type', operation: 'count' }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'sent',
|
|
||||||
filter: { 'state.type': 'sent' },
|
|
||||||
rollups: [{ name: 'sent', property: 'state.type', operation: 'count' }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'confirmed',
|
|
||||||
filter: { 'state.type': 'confirmed' },
|
|
||||||
rollups: [{ name: 'confirmed', property: 'state.type', operation: 'count' }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'partiallyShipped',
|
|
||||||
filter: { 'state.type': 'partiallyShipped' },
|
|
||||||
rollups: [{ name: 'partiallyShipped', property: 'state.type', operation: 'count' }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'shipped',
|
|
||||||
filter: { 'state.type': 'shipped' },
|
|
||||||
rollups: [{ name: 'shipped', property: 'state.type', operation: 'count' }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'partiallyDelivered',
|
|
||||||
filter: { 'state.type': 'partiallyDelivered' },
|
|
||||||
rollups: [{ name: 'partiallyDelivered', property: 'state.type', operation: 'count' }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'delivered',
|
|
||||||
filter: { 'state.type': 'delivered' },
|
|
||||||
rollups: [{ name: 'delivered', property: 'state.type', operation: 'count' }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'cancelled',
|
|
||||||
filter: { 'state.type': 'cancelled' },
|
|
||||||
rollups: [{ name: 'cancelled', property: 'state.type', operation: 'count' }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'completed',
|
|
||||||
filter: { 'state.type': 'completed' },
|
|
||||||
rollups: [{ name: 'completed', property: 'state.type', operation: 'count' }],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
salesOrderSchema.statics.stats = async function () {
|
|
||||||
const results = await aggregateRollups({
|
|
||||||
model: this,
|
|
||||||
rollupConfigs: rollupConfigs,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Transform the results to match the expected format
|
|
||||||
return results;
|
|
||||||
};
|
|
||||||
|
|
||||||
salesOrderSchema.statics.history = async function (from, to) {
|
|
||||||
const results = await aggregateRollupsHistory({
|
|
||||||
model: this,
|
|
||||||
startDate: from,
|
|
||||||
endDate: to,
|
|
||||||
rollupConfigs: rollupConfigs,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Return time-series data array
|
|
||||||
return results;
|
|
||||||
};
|
|
||||||
|
|
||||||
salesOrderSchema.statics.recalculate = async function (salesOrder, user) {
|
|
||||||
const orderId = salesOrder._id || salesOrder;
|
|
||||||
if (!orderId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const orderItemModel = mongoose.model('orderItem');
|
|
||||||
const shipmentModel = mongoose.model('shipment');
|
|
||||||
|
|
||||||
const orderIdObj = new mongoose.Types.ObjectId(orderId);
|
|
||||||
const baseFilter = { order: orderIdObj, orderType: 'salesOrder' };
|
|
||||||
|
|
||||||
const orderItemRollupResults = await aggregateRollups({
|
|
||||||
model: orderItemModel,
|
|
||||||
baseFilter,
|
|
||||||
rollupConfigs: [
|
|
||||||
{
|
|
||||||
name: 'orderTotals',
|
|
||||||
rollups: [
|
|
||||||
{ name: 'totalAmount', property: 'totalAmount', operation: 'sum' },
|
|
||||||
{ name: 'totalAmountWithTax', property: 'totalAmountWithTax', operation: 'sum' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'overallCount',
|
|
||||||
rollups: [{ name: 'overallCount', property: '_id', operation: 'count' }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'shipped',
|
|
||||||
filter: { 'state.type': 'shipped' },
|
|
||||||
rollups: [{ name: 'shipped', property: 'state.type', operation: 'count' }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'received',
|
|
||||||
filter: { 'state.type': 'received' },
|
|
||||||
rollups: [{ name: 'received', property: 'state.type', operation: 'count' }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const shipmentRollupResults = await aggregateRollups({
|
|
||||||
model: shipmentModel,
|
|
||||||
baseFilter,
|
|
||||||
rollupConfigs: [
|
|
||||||
{
|
|
||||||
name: 'shipmentTotals',
|
|
||||||
rollups: [
|
|
||||||
{ name: 'amount', property: 'amount', operation: 'sum' },
|
|
||||||
{ name: 'amountWithTax', property: 'amountWithTax', operation: 'sum' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const orderTotals = orderItemRollupResults.orderTotals || {};
|
|
||||||
const totalAmount = orderTotals.totalAmount?.sum?.toFixed(2) || 0;
|
|
||||||
const totalAmountWithTax = orderTotals.totalAmountWithTax?.sum?.toFixed(2) || 0;
|
|
||||||
|
|
||||||
const shipmentTotals = shipmentRollupResults.shipmentTotals || {};
|
|
||||||
const totalShippingAmount = shipmentTotals.amount?.sum?.toFixed(2) || 0;
|
|
||||||
const totalShippingAmountWithTax = shipmentTotals.amountWithTax?.sum?.toFixed(2) || 0;
|
|
||||||
|
|
||||||
const grandTotalAmount =
|
|
||||||
parseFloat(totalAmountWithTax || 0) + parseFloat(totalShippingAmountWithTax || 0);
|
|
||||||
|
|
||||||
const overallCount = orderItemRollupResults.overallCount?.count || 0;
|
|
||||||
const shippedCount = orderItemRollupResults.shipped?.count || 0;
|
|
||||||
const receivedCount = orderItemRollupResults.received?.count || 0;
|
|
||||||
|
|
||||||
let updateData = {
|
|
||||||
totalAmount: parseFloat(totalAmount).toFixed(2),
|
|
||||||
totalAmountWithTax: parseFloat(totalAmountWithTax).toFixed(2),
|
|
||||||
totalTaxAmount: parseFloat((totalAmountWithTax - totalAmount).toFixed(2)),
|
|
||||||
shippingAmount: parseFloat(totalShippingAmount).toFixed(2),
|
|
||||||
shippingAmountWithTax: parseFloat(totalShippingAmountWithTax).toFixed(2),
|
|
||||||
grandTotalAmount: parseFloat(grandTotalAmount).toFixed(2),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (shippedCount > 0 && shippedCount < overallCount) {
|
|
||||||
updateData = { ...updateData, state: { type: 'partiallyShipped' } };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shippedCount > 0 && shippedCount === overallCount) {
|
|
||||||
updateData = { ...updateData, state: { type: 'shipped' } };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (receivedCount > 0 && receivedCount < overallCount) {
|
|
||||||
updateData = { ...updateData, state: { type: 'partiallyDelivered' } };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (receivedCount > 0 && receivedCount === overallCount) {
|
|
||||||
updateData = { ...updateData, state: { type: 'delivered' } };
|
|
||||||
}
|
|
||||||
|
|
||||||
await editObject({
|
|
||||||
model: this,
|
|
||||||
id: orderId,
|
|
||||||
updateData,
|
|
||||||
user,
|
|
||||||
recalculate: false,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add virtual id getter
|
|
||||||
salesOrderSchema.virtual('id').get(function () {
|
|
||||||
return this._id;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Configure JSON serialization to include virtuals
|
|
||||||
salesOrderSchema.set('toJSON', { virtuals: true });
|
|
||||||
|
|
||||||
// Create and export the model
|
|
||||||
export const salesOrderModel = mongoose.model('salesOrder', salesOrderSchema);
|
|
||||||
@ -1,85 +1,7 @@
|
|||||||
import { customAlphabet } from 'nanoid';
|
import { customAlphabet } from 'nanoid';
|
||||||
import mongoose from 'mongoose';
|
|
||||||
|
|
||||||
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||||
export const generateId = () => {
|
export const generateId = () => {
|
||||||
// 10 characters
|
// 10 characters
|
||||||
return customAlphabet(ALPHABET, 12);
|
return customAlphabet(ALPHABET, 12);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Check if a value is a string that looks like a MongoDB ObjectId (24 hex chars). */
|
|
||||||
export function isObjectIdString(value) {
|
|
||||||
return typeof value === 'string' && /^[a-f\d]{24}$/i.test(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Convert a value to ObjectId if it's a valid ObjectId string; otherwise return as-is. */
|
|
||||||
export function toObjectIdIfValid(value) {
|
|
||||||
if (isObjectIdString(value)) {
|
|
||||||
return new mongoose.Types.ObjectId(value);
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Extract an ObjectId from a populated reference object, if applicable. */
|
|
||||||
function extractReferenceId(value) {
|
|
||||||
if (
|
|
||||||
!value ||
|
|
||||||
typeof value !== 'object' ||
|
|
||||||
Array.isArray(value) ||
|
|
||||||
value instanceof mongoose.Types.ObjectId ||
|
|
||||||
value instanceof Date ||
|
|
||||||
value._id == null
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const keys = Object.keys(value);
|
|
||||||
const looksLikeReference = keys.every((k) => k === '_id' || k === '__v');
|
|
||||||
if (!looksLikeReference) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = value._id;
|
|
||||||
if (id instanceof mongoose.Types.ObjectId) {
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
return toObjectIdIfValid(id?.toString?.() ?? id);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Recursively convert ObjectId strings to ObjectId in a filter object for MongoDB $match. */
|
|
||||||
export function convertObjectIdStringsInFilter(filter) {
|
|
||||||
if (!filter || typeof filter !== 'object') return filter;
|
|
||||||
|
|
||||||
const result = {};
|
|
||||||
for (const [key, value] of Object.entries(filter)) {
|
|
||||||
if (key.startsWith('$')) {
|
|
||||||
if ((key === '$in' || key === '$nin') && Array.isArray(value)) {
|
|
||||||
result[key] = value.map((v) => {
|
|
||||||
const refId = extractReferenceId(v);
|
|
||||||
if (refId !== null) return refId;
|
|
||||||
return isObjectIdString(v) ? new mongoose.Types.ObjectId(v) : v;
|
|
||||||
});
|
|
||||||
} else if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
||||||
result[key] = convertObjectIdStringsInFilter(value);
|
|
||||||
} else {
|
|
||||||
result[key] = toObjectIdIfValid(value);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const refId = extractReferenceId(value);
|
|
||||||
if (refId !== null) {
|
|
||||||
result[key] = refId;
|
|
||||||
} else if (
|
|
||||||
value &&
|
|
||||||
typeof value === 'object' &&
|
|
||||||
!Array.isArray(value) &&
|
|
||||||
!(value instanceof mongoose.Types.ObjectId) &&
|
|
||||||
!(value instanceof Date)
|
|
||||||
) {
|
|
||||||
result[key] = convertObjectIdStringsInFilter(value);
|
|
||||||
} else {
|
|
||||||
result[key] = toObjectIdIfValid(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|||||||
86
src/index.js
86
src/index.js
@ -2,38 +2,28 @@ import express from 'express';
|
|||||||
import bodyParser from 'body-parser';
|
import bodyParser from 'body-parser';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import config from './config.js';
|
import config from './config.js';
|
||||||
|
import { expressSession, keycloak } from './keycloak.js';
|
||||||
import { dbConnect } from './database/mongo.js';
|
import { dbConnect } from './database/mongo.js';
|
||||||
import { redisServer } from './database/redis.js';
|
|
||||||
import {
|
import {
|
||||||
authRoutes,
|
authRoutes,
|
||||||
userRoutes,
|
userRoutes,
|
||||||
appPasswordRoutes,
|
|
||||||
fileRoutes,
|
fileRoutes,
|
||||||
printerRoutes,
|
printerRoutes,
|
||||||
printerProfileRoutes,
|
|
||||||
filamentProfileRoutes,
|
|
||||||
jobRoutes,
|
jobRoutes,
|
||||||
subJobRoutes,
|
subJobRoutes,
|
||||||
gcodeFileRoutes,
|
gcodeFileRoutes,
|
||||||
filamentRoutes,
|
filamentRoutes,
|
||||||
filamentSkuRoutes,
|
|
||||||
spotlightRoutes,
|
spotlightRoutes,
|
||||||
partRoutes,
|
partRoutes,
|
||||||
partSkuRoutes,
|
|
||||||
productRoutes,
|
productRoutes,
|
||||||
productCategoryRoutes,
|
|
||||||
productSkuRoutes,
|
|
||||||
vendorRoutes,
|
vendorRoutes,
|
||||||
materialRoutes,
|
materialRoutes,
|
||||||
partStockRoutes,
|
partStockRoutes,
|
||||||
productStockRoutes,
|
|
||||||
filamentStockRoutes,
|
filamentStockRoutes,
|
||||||
purchaseOrderRoutes,
|
purchaseOrderRoutes,
|
||||||
orderItemRoutes,
|
orderItemRoutes,
|
||||||
shipmentRoutes,
|
shipmentRoutes,
|
||||||
stockAuditRoutes,
|
stockAuditRoutes,
|
||||||
stockLocationRoutes,
|
|
||||||
stockTransferRoutes,
|
|
||||||
stockEventRoutes,
|
stockEventRoutes,
|
||||||
auditLogRoutes,
|
auditLogRoutes,
|
||||||
noteTypeRoutes,
|
noteTypeRoutes,
|
||||||
@ -47,23 +37,6 @@ import {
|
|||||||
courierServiceRoutes,
|
courierServiceRoutes,
|
||||||
taxRateRoutes,
|
taxRateRoutes,
|
||||||
taxRecordRoutes,
|
taxRecordRoutes,
|
||||||
invoiceRoutes,
|
|
||||||
paymentRoutes,
|
|
||||||
clientRoutes,
|
|
||||||
salesOrderRoutes,
|
|
||||||
marketplaceRoutes,
|
|
||||||
listingRoutes,
|
|
||||||
listingVarientRoutes,
|
|
||||||
userNotifierRoutes,
|
|
||||||
notificationRoutes,
|
|
||||||
odataRoutes,
|
|
||||||
rssRoutes,
|
|
||||||
excelRoutes,
|
|
||||||
csvRoutes,
|
|
||||||
appLaunchRoutes,
|
|
||||||
appUpdateRoutes,
|
|
||||||
serverRoutes,
|
|
||||||
slicerRoutes,
|
|
||||||
} from './routes/index.js';
|
} from './routes/index.js';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
@ -81,22 +54,14 @@ logger.level = config.server.logLevel;
|
|||||||
|
|
||||||
app.use(log4js.connectLogger(logger, { level: 'trace' }));
|
app.use(log4js.connectLogger(logger, { level: 'trace' }));
|
||||||
|
|
||||||
const allowedOrigins = [
|
const whitelist = [config.app.urlClient, config.app.urlElectronClient];
|
||||||
...new Set(
|
|
||||||
[
|
|
||||||
...(config.server.corsOrigins || []),
|
|
||||||
config.app.urlClient,
|
|
||||||
config.app.urlElectronClient,
|
|
||||||
].filter(Boolean)
|
|
||||||
),
|
|
||||||
];
|
|
||||||
const corsOptions = {
|
const corsOptions = {
|
||||||
origin: function (origin, callback) {
|
origin: function (origin, callback) {
|
||||||
if (!origin) return callback(null, true);
|
if (!origin || whitelist.indexOf(origin) !== -1) {
|
||||||
if (allowedOrigins.includes('*')) return callback(null, true);
|
callback(null, true);
|
||||||
if (allowedOrigins.includes(origin)) return callback(null, true);
|
} else {
|
||||||
logger.warn(`Allowed origins: ${allowedOrigins.join(', ')}`);
|
callback(new Error('Not allowed by CORS'));
|
||||||
callback(new Error(`Not allowed by CORS - ${origin}`));
|
}
|
||||||
},
|
},
|
||||||
credentials: true,
|
credentials: true,
|
||||||
};
|
};
|
||||||
@ -111,9 +76,6 @@ async function initializeApp() {
|
|||||||
// Connect to database
|
// Connect to database
|
||||||
await dbConnect();
|
await dbConnect();
|
||||||
|
|
||||||
// Connect to Redis (required for excel temp tokens, sessions, cache)
|
|
||||||
await redisServer.connect();
|
|
||||||
|
|
||||||
// Connect to NATS
|
// Connect to NATS
|
||||||
await natsServer.connect();
|
await natsServer.connect();
|
||||||
|
|
||||||
@ -127,7 +89,6 @@ async function initializeApp() {
|
|||||||
|
|
||||||
// Start server
|
// Start server
|
||||||
app.listen(PORT, () => logger.info(`Server listening to port ${PORT}`));
|
app.listen(PORT, () => logger.info(`Server listening to port ${PORT}`));
|
||||||
logger.info(`Allowed origins: ${allowedOrigins.join(', ')}`);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Failed to initialize application:', err);
|
logger.error('Failed to initialize application:', err);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
@ -138,6 +99,8 @@ async function initializeApp() {
|
|||||||
app.use(cors(corsOptions));
|
app.use(cors(corsOptions));
|
||||||
app.use(bodyParser.json({ type: 'application/json', strict: false, limit: '50mb' }));
|
app.use(bodyParser.json({ type: 'application/json', strict: false, limit: '50mb' }));
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
app.use(expressSession);
|
||||||
|
app.use(keycloak.middleware());
|
||||||
app.use(populateUserMiddleware);
|
app.use(populateUserMiddleware);
|
||||||
|
|
||||||
app.get('/', function (req, res) {
|
app.get('/', function (req, res) {
|
||||||
@ -147,35 +110,25 @@ app.get('/', function (req, res) {
|
|||||||
|
|
||||||
app.use('/auth', authRoutes);
|
app.use('/auth', authRoutes);
|
||||||
app.use('/users', userRoutes);
|
app.use('/users', userRoutes);
|
||||||
app.use('/apppasswords', appPasswordRoutes);
|
|
||||||
app.use('/files', fileRoutes);
|
app.use('/files', fileRoutes);
|
||||||
app.use('/spotlight', spotlightRoutes);
|
app.use('/spotlight', spotlightRoutes);
|
||||||
app.use('/printers', printerRoutes);
|
app.use('/printers', printerRoutes);
|
||||||
app.use('/printerprofiles', printerProfileRoutes);
|
|
||||||
app.use('/filamentprofiles', filamentProfileRoutes);
|
|
||||||
app.use('/hosts', hostRoutes);
|
app.use('/hosts', hostRoutes);
|
||||||
app.use('/jobs', jobRoutes);
|
app.use('/jobs', jobRoutes);
|
||||||
app.use('/subjobs', subJobRoutes);
|
app.use('/subjobs', subJobRoutes);
|
||||||
app.use('/gcodefiles', gcodeFileRoutes);
|
app.use('/gcodefiles', gcodeFileRoutes);
|
||||||
app.use('/filaments', filamentRoutes);
|
app.use('/filaments', filamentRoutes);
|
||||||
app.use('/filamentskus', filamentSkuRoutes);
|
|
||||||
app.use('/parts', partRoutes);
|
app.use('/parts', partRoutes);
|
||||||
app.use('/partskus', partSkuRoutes);
|
|
||||||
app.use('/products', productRoutes);
|
app.use('/products', productRoutes);
|
||||||
app.use('/productcategories', productCategoryRoutes);
|
|
||||||
app.use('/productskus', productSkuRoutes);
|
|
||||||
app.use('/vendors', vendorRoutes);
|
app.use('/vendors', vendorRoutes);
|
||||||
app.use('/materials', materialRoutes);
|
app.use('/materials', materialRoutes);
|
||||||
app.use('/partstocks', partStockRoutes);
|
app.use('/partstocks', partStockRoutes);
|
||||||
app.use('/productstocks', productStockRoutes);
|
|
||||||
app.use('/filamentstocks', filamentStockRoutes);
|
app.use('/filamentstocks', filamentStockRoutes);
|
||||||
app.use('/purchaseorders', purchaseOrderRoutes);
|
app.use('/purchaseorders', purchaseOrderRoutes);
|
||||||
app.use('/orderitems', orderItemRoutes);
|
app.use('/orderitems', orderItemRoutes);
|
||||||
app.use('/shipments', shipmentRoutes);
|
app.use('/shipments', shipmentRoutes);
|
||||||
app.use('/stockevents', stockEventRoutes);
|
app.use('/stockevents', stockEventRoutes);
|
||||||
app.use('/stockaudits', stockAuditRoutes);
|
app.use('/stockaudits', stockAuditRoutes);
|
||||||
app.use('/stocklocations', stockLocationRoutes);
|
|
||||||
app.use('/stocktransfers', stockTransferRoutes);
|
|
||||||
app.use('/auditlogs', auditLogRoutes);
|
app.use('/auditlogs', auditLogRoutes);
|
||||||
app.use('/notetypes', noteTypeRoutes);
|
app.use('/notetypes', noteTypeRoutes);
|
||||||
app.use('/documentsizes', documentSizesRoutes);
|
app.use('/documentsizes', documentSizesRoutes);
|
||||||
@ -186,28 +139,7 @@ app.use('/couriers', courierRoutes);
|
|||||||
app.use('/courierservices', courierServiceRoutes);
|
app.use('/courierservices', courierServiceRoutes);
|
||||||
app.use('/taxrates', taxRateRoutes);
|
app.use('/taxrates', taxRateRoutes);
|
||||||
app.use('/taxrecords', taxRecordRoutes);
|
app.use('/taxrecords', taxRecordRoutes);
|
||||||
app.use('/invoices', invoiceRoutes);
|
|
||||||
app.use('/payments', paymentRoutes);
|
|
||||||
app.use('/clients', clientRoutes);
|
|
||||||
app.use('/salesorders', salesOrderRoutes);
|
|
||||||
app.use('/marketplaces', marketplaceRoutes);
|
|
||||||
app.use('/listings', listingRoutes);
|
|
||||||
app.use('/listingvarients', listingVarientRoutes);
|
|
||||||
app.use('/notes', noteRoutes);
|
app.use('/notes', noteRoutes);
|
||||||
app.use('/usernotifiers', userNotifierRoutes);
|
|
||||||
app.use('/notifications', notificationRoutes);
|
|
||||||
app.use('/odata', odataRoutes);
|
|
||||||
app.use('/rss', rssRoutes);
|
|
||||||
app.use('/excel', excelRoutes);
|
|
||||||
app.use('/csv', csvRoutes);
|
|
||||||
app.use('/applaunch', appLaunchRoutes);
|
|
||||||
app.use('/appupdate', appUpdateRoutes);
|
|
||||||
app.use('/server', serverRoutes);
|
|
||||||
app.use('/slicer', slicerRoutes);
|
|
||||||
|
|
||||||
// Start the application
|
// Start the application
|
||||||
if (process.env.NODE_ENV !== 'test') {
|
|
||||||
initializeApp();
|
initializeApp();
|
||||||
}
|
|
||||||
|
|
||||||
export default app;
|
|
||||||
|
|||||||
@ -1,163 +0,0 @@
|
|||||||
import crypto from 'crypto';
|
|
||||||
import {
|
|
||||||
getApiBaseUrl,
|
|
||||||
getAuthorizeBaseUrl,
|
|
||||||
getScopes,
|
|
||||||
getScopesString,
|
|
||||||
getTokenExpiryDate,
|
|
||||||
isAccessTokenExpired,
|
|
||||||
getRequiredAuthConfig,
|
|
||||||
getBasicAuthHeader,
|
|
||||||
logger,
|
|
||||||
} from './shared.js';
|
|
||||||
|
|
||||||
const TOKEN_PATH = '/identity/v1/oauth2/token';
|
|
||||||
|
|
||||||
async function mintToken(marketplace, body) {
|
|
||||||
const response = await fetch(`${getApiBaseUrl(marketplace)}${TOKEN_PATH}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
|
||||||
Authorization: getBasicAuthHeader(marketplace),
|
|
||||||
},
|
|
||||||
body: new URLSearchParams(body).toString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
if (!response.ok || data.error) {
|
|
||||||
const message = data.error_description || data.error || response.statusText;
|
|
||||||
logger.error(`eBay token request failed: ${message}`);
|
|
||||||
throw new Error(`eBay token request failed: ${message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createAuthorizationUrl(marketplace, { state } = {}) {
|
|
||||||
const { clientId } = getRequiredAuthConfig(marketplace);
|
|
||||||
const { ruName, locale, prompt } = marketplace.config || {};
|
|
||||||
|
|
||||||
if (!ruName) {
|
|
||||||
throw new Error('eBay marketplace is missing required config (ruName)');
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = new URL('/oauth2/authorize', getAuthorizeBaseUrl(marketplace));
|
|
||||||
url.searchParams.set('client_id', clientId);
|
|
||||||
url.searchParams.set('redirect_uri', ruName);
|
|
||||||
url.searchParams.set('response_type', 'code');
|
|
||||||
url.searchParams.set('scope', getScopesString(marketplace));
|
|
||||||
|
|
||||||
if (state) {
|
|
||||||
url.searchParams.set('state', state);
|
|
||||||
}
|
|
||||||
if (locale) {
|
|
||||||
url.searchParams.set('locale', locale);
|
|
||||||
}
|
|
||||||
if (prompt) {
|
|
||||||
url.searchParams.set('prompt', prompt);
|
|
||||||
}
|
|
||||||
|
|
||||||
return url.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function exchangeAuthorizationCode(marketplace, { code }) {
|
|
||||||
const { ruName } = marketplace.config || {};
|
|
||||||
if (!code) {
|
|
||||||
throw new Error('Missing eBay authorization code');
|
|
||||||
}
|
|
||||||
if (!ruName) {
|
|
||||||
throw new Error('eBay marketplace is missing required config (ruName)');
|
|
||||||
}
|
|
||||||
|
|
||||||
const tokenData = await mintToken(marketplace, {
|
|
||||||
grant_type: 'authorization_code',
|
|
||||||
code,
|
|
||||||
redirect_uri: ruName,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
configUpdates: {
|
|
||||||
accessToken: tokenData.access_token,
|
|
||||||
accessTokenExpiresAt: getTokenExpiryDate(tokenData.expires_in).toISOString(),
|
|
||||||
refreshToken: tokenData.refresh_token || marketplace.config.refreshToken,
|
|
||||||
scopes: getScopes(marketplace),
|
|
||||||
tokenType: tokenData.token_type,
|
|
||||||
},
|
|
||||||
marketplaceUpdates: {
|
|
||||||
connected: true,
|
|
||||||
connectedAt: new Date(),
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
expiresIn: tokenData.expires_in,
|
|
||||||
tokenType: tokenData.token_type,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function refreshAuth(marketplace) {
|
|
||||||
const { refreshToken } = marketplace.config || {};
|
|
||||||
if (!refreshToken) {
|
|
||||||
throw new Error('eBay marketplace is missing required config (refreshToken)');
|
|
||||||
}
|
|
||||||
|
|
||||||
const tokenData = await mintToken(marketplace, {
|
|
||||||
grant_type: 'refresh_token',
|
|
||||||
refresh_token: refreshToken,
|
|
||||||
scope: getScopesString(marketplace),
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
configUpdates: {
|
|
||||||
accessToken: tokenData.access_token,
|
|
||||||
accessTokenExpiresAt: getTokenExpiryDate(tokenData.expires_in).toISOString(),
|
|
||||||
refreshToken: tokenData.refresh_token || refreshToken,
|
|
||||||
scopes: getScopes(marketplace),
|
|
||||||
tokenType: tokenData.token_type,
|
|
||||||
lastTokenRefreshAt: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
expiresIn: tokenData.expires_in,
|
|
||||||
tokenType: tokenData.token_type,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function ensureAuthenticatedMarketplace(marketplace) {
|
|
||||||
if (!isAccessTokenExpired(marketplace)) {
|
|
||||||
return { marketplace };
|
|
||||||
}
|
|
||||||
|
|
||||||
const authResult = await refreshAuth(marketplace);
|
|
||||||
return {
|
|
||||||
marketplace: {
|
|
||||||
...marketplace,
|
|
||||||
config: {
|
|
||||||
...(marketplace.config || {}),
|
|
||||||
...authResult.configUpdates,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
configUpdates: authResult.configUpdates,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function canVerifyWebhookSignature(marketplace) {
|
|
||||||
return !!marketplace.config?.verificationToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function verifyWebhookSignature(marketplace, rawBody, signature) {
|
|
||||||
const verificationToken = marketplace.config?.verificationToken;
|
|
||||||
if (!verificationToken) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hash = crypto
|
|
||||||
.createHash('sha256')
|
|
||||||
.update(rawBody + verificationToken)
|
|
||||||
.digest('base64');
|
|
||||||
|
|
||||||
try {
|
|
||||||
return crypto.timingSafeEqual(Buffer.from(hash), Buffer.from(signature));
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,121 +0,0 @@
|
|||||||
import mongoose from 'mongoose';
|
|
||||||
import { productModel } from '../../../database/schemas/management/product.schema.js';
|
|
||||||
import { productCategoryModel } from '../../../database/schemas/management/productcategory.schema.js';
|
|
||||||
import { makeRequest, logger } from './shared.js';
|
|
||||||
|
|
||||||
const categoryTreeIds = new Map();
|
|
||||||
const categoryMatches = new Map();
|
|
||||||
|
|
||||||
function isPopulated(value) {
|
|
||||||
return value && typeof value === 'object' && !(value instanceof mongoose.Types.ObjectId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveProduct(listing, varients) {
|
|
||||||
const productRef = listing?.product || varients?.find((varient) => varient?.product)?.product;
|
|
||||||
if (!productRef) return null;
|
|
||||||
|
|
||||||
if (isPopulated(productRef) && productRef.productCategory) {
|
|
||||||
return productRef;
|
|
||||||
}
|
|
||||||
|
|
||||||
const productId = productRef._id || productRef;
|
|
||||||
return productModel.findById(productId).populate('productCategory').lean();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveProductCategory(listing, varients) {
|
|
||||||
const product = await resolveProduct(listing, varients);
|
|
||||||
const categoryRef = product?.productCategory;
|
|
||||||
if (!categoryRef) return null;
|
|
||||||
|
|
||||||
if (isPopulated(categoryRef) && categoryRef.name) {
|
|
||||||
return categoryRef;
|
|
||||||
}
|
|
||||||
|
|
||||||
const categoryId = categoryRef._id || categoryRef;
|
|
||||||
return productCategoryModel.findById(categoryId).lean();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchDefaultCategoryTreeId(marketplace) {
|
|
||||||
const marketplaceId = marketplace.config?.marketplaceId || 'EBAY_GB';
|
|
||||||
const cacheKey = `${marketplace.config?.sandbox ? 'sandbox' : 'production'}:${marketplaceId}`;
|
|
||||||
if (categoryTreeIds.has(cacheKey)) {
|
|
||||||
return categoryTreeIds.get(cacheKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await makeRequest({
|
|
||||||
marketplace,
|
|
||||||
path: '/commerce/taxonomy/v1/get_default_category_tree_id',
|
|
||||||
params: { marketplace_id: marketplaceId },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!result?.categoryTreeId) {
|
|
||||||
throw new Error(`eBay did not return a category tree for marketplace "${marketplaceId}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
categoryTreeIds.set(cacheKey, result.categoryTreeId);
|
|
||||||
return result.categoryTreeId;
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectCategorySuggestion(suggestions, categoryName) {
|
|
||||||
const normalizedName = categoryName.trim().toLocaleLowerCase();
|
|
||||||
return (
|
|
||||||
suggestions.find(
|
|
||||||
(suggestion) =>
|
|
||||||
suggestion?.category?.categoryName?.trim().toLocaleLowerCase() === normalizedName
|
|
||||||
) || suggestions[0]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves a FarmControl product category to the closest category in eBay's
|
|
||||||
* marketplace taxonomy. eBay owns its category tree, so categories are matched
|
|
||||||
* rather than created.
|
|
||||||
*/
|
|
||||||
export async function syncProductCategory(marketplace, listing, varients = []) {
|
|
||||||
if (listing?.categoryId) {
|
|
||||||
return { categoryId: String(listing.categoryId) };
|
|
||||||
}
|
|
||||||
|
|
||||||
const productCategory = await resolveProductCategory(listing, varients);
|
|
||||||
if (!productCategory?.name) {
|
|
||||||
logger.debug(
|
|
||||||
`No product category found for listing "${listing?._reference}"; skipping eBay category sync`
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const categoryTreeId = await fetchDefaultCategoryTreeId(marketplace);
|
|
||||||
const marketplaceId = marketplace.config?.marketplaceId || 'EBAY_GB';
|
|
||||||
const cacheKey = `${categoryTreeId}:${productCategory.name.trim().toLocaleLowerCase()}`;
|
|
||||||
if (categoryMatches.has(cacheKey)) {
|
|
||||||
return categoryMatches.get(cacheKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await makeRequest({
|
|
||||||
marketplace,
|
|
||||||
path: `/commerce/taxonomy/v1/category_tree/${encodeURIComponent(
|
|
||||||
categoryTreeId
|
|
||||||
)}/get_category_suggestions`,
|
|
||||||
params: { q: productCategory.name },
|
|
||||||
});
|
|
||||||
const suggestion = selectCategorySuggestion(
|
|
||||||
result?.categorySuggestions || [],
|
|
||||||
productCategory.name
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!suggestion?.category?.categoryId) {
|
|
||||||
throw new Error(
|
|
||||||
`No eBay category found for product category "${productCategory.name}" on ${marketplaceId}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const category = {
|
|
||||||
categoryId: String(suggestion.category.categoryId),
|
|
||||||
categoryName: suggestion.category.categoryName,
|
|
||||||
};
|
|
||||||
categoryMatches.set(cacheKey, category);
|
|
||||||
logger.info(
|
|
||||||
`Matched product category "${productCategory.name}" to eBay category "${category.categoryName}" (${category.categoryId})`
|
|
||||||
);
|
|
||||||
return category;
|
|
||||||
}
|
|
||||||
@ -1,102 +0,0 @@
|
|||||||
/**
|
|
||||||
* Maps FarmControl country codes (farmcontrol-ui/src/database/Countries.js)
|
|
||||||
* to eBay Sell Inventory API CountryCodeEnum values.
|
|
||||||
* @see https://developer.ebay.com/api-docs/sell/inventory/types/ba:CountryCodeEnum
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** @type {Set<string>} */
|
|
||||||
export const EBAY_COUNTRY_CODES = new Set([
|
|
||||||
'AD', 'AE', 'AF', 'AG', 'AI', 'AL', 'AM', 'AN', 'AO', 'AQ', 'AR', 'AS', 'AT', 'AU', 'AW', 'AX', 'AZ',
|
|
||||||
'BA', 'BB', 'BD', 'BE', 'BF', 'BG', 'BH', 'BI', 'BJ', 'BL', 'BM', 'BN', 'BO', 'BQ', 'BR', 'BS', 'BT',
|
|
||||||
'BV', 'BW', 'BY', 'BZ', 'CA', 'CC', 'CD', 'CF', 'CG', 'CH', 'CI', 'CK', 'CL', 'CM', 'CN', 'CO', 'CR',
|
|
||||||
'CU', 'CV', 'CW', 'CX', 'CY', 'CZ', 'DE', 'DJ', 'DK', 'DM', 'DO', 'DZ', 'EC', 'EE', 'EG', 'EH', 'ER',
|
|
||||||
'ES', 'ET', 'FI', 'FJ', 'FK', 'FM', 'FO', 'FR', 'GA', 'GB', 'GD', 'GE', 'GF', 'GG', 'GH', 'GI', 'GL',
|
|
||||||
'GM', 'GN', 'GP', 'GQ', 'GR', 'GS', 'GT', 'GU', 'GW', 'GY', 'HK', 'HM', 'HN', 'HR', 'HT', 'HU', 'ID',
|
|
||||||
'IE', 'IL', 'IM', 'IN', 'IO', 'IQ', 'IR', 'IS', 'IT', 'JE', 'JM', 'JO', 'JP', 'KE', 'KG', 'KH', 'KI',
|
|
||||||
'KM', 'KN', 'KP', 'KR', 'KW', 'KY', 'KZ', 'LA', 'LB', 'LC', 'LI', 'LK', 'LR', 'LS', 'LT', 'LU', 'LV',
|
|
||||||
'LY', 'MA', 'MC', 'MD', 'ME', 'MF', 'MG', 'MH', 'MK', 'ML', 'MM', 'MN', 'MO', 'MP', 'MQ', 'MR', 'MS',
|
|
||||||
'MT', 'MU', 'MV', 'MW', 'MX', 'MY', 'MZ', 'NA', 'NC', 'NE', 'NF', 'NG', 'NI', 'NL', 'NO', 'NP', 'NR',
|
|
||||||
'NU', 'NZ', 'OM', 'PA', 'PE', 'PF', 'PG', 'PH', 'PK', 'PL', 'PM', 'PN', 'PR', 'PS', 'PT', 'PW', 'PY',
|
|
||||||
'QA', 'RE', 'RO', 'RS', 'RU', 'RW', 'SA', 'SB', 'SC', 'SD', 'SE', 'SG', 'SH', 'SI', 'SJ', 'SK', 'SL',
|
|
||||||
'SM', 'SN', 'SO', 'SR', 'ST', 'SV', 'SX', 'SY', 'SZ', 'TC', 'TD', 'TF', 'TG', 'TH', 'TJ', 'TK', 'TL',
|
|
||||||
'TM', 'TN', 'TO', 'TR', 'TT', 'TV', 'TW', 'TZ', 'UA', 'UG', 'UM', 'US', 'UY', 'UZ', 'VA', 'VC', 'VE',
|
|
||||||
'VG', 'VI', 'VN', 'VU', 'WF', 'WS', 'YE', 'YT', 'ZA', 'ZM', 'ZW',
|
|
||||||
]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* FarmControl-only codes (Countries.js) that must map to an eBay enum value.
|
|
||||||
*/
|
|
||||||
const FARMCONTROL_TO_EBAY = {
|
|
||||||
UK: 'GB',
|
|
||||||
'GB-ENG': 'GB',
|
|
||||||
'GB-NIR': 'GB',
|
|
||||||
'GB-SCT': 'GB',
|
|
||||||
'GB-UKM': 'GB',
|
|
||||||
'GB-WLS': 'GB',
|
|
||||||
'BQ-BO': 'BQ',
|
|
||||||
'BQ-SA': 'BQ',
|
|
||||||
'BQ-SE': 'BQ',
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* FarmControl codes with no eBay equivalent — sync/publish will fail with a clear message.
|
|
||||||
*/
|
|
||||||
const FARMCONTROL_UNSUPPORTED_ON_EBAY = new Set(['SS']);
|
|
||||||
|
|
||||||
/** State/province hints when a UK subdivision code is mapped to GB. */
|
|
||||||
export const FARMCONTROL_GB_SUBDIVISION_STATE = {
|
|
||||||
'GB-ENG': 'England',
|
|
||||||
'GB-NIR': 'Northern Ireland',
|
|
||||||
'GB-SCT': 'Scotland',
|
|
||||||
'GB-WLS': 'Wales',
|
|
||||||
'GB-UKM': 'England',
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string | null | undefined} farmControlCountryCode
|
|
||||||
* @returns {string | null} eBay CountryCodeEnum or null if missing/blank
|
|
||||||
*/
|
|
||||||
export function toEbayCountryCode(farmControlCountryCode) {
|
|
||||||
if (farmControlCountryCode == null || typeof farmControlCountryCode !== 'string') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalized = farmControlCountryCode.trim().toUpperCase();
|
|
||||||
if (!normalized) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (FARMCONTROL_UNSUPPORTED_ON_EBAY.has(normalized)) {
|
|
||||||
throw new Error(
|
|
||||||
`Country "${normalized}" is not supported by eBay. Choose a different stock location country.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const mapped = FARMCONTROL_TO_EBAY[normalized];
|
|
||||||
if (mapped) {
|
|
||||||
return mapped;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (EBAY_COUNTRY_CODES.has(normalized)) {
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(
|
|
||||||
`Country "${normalized}" is not supported by eBay. Use a country from the stock location address list.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string | null | undefined} farmControlCountryCode
|
|
||||||
* @returns {{ ebayCountryCode: string, farmControlCountryCode: string } | null}
|
|
||||||
*/
|
|
||||||
export function resolveEbayCountry(farmControlCountryCode) {
|
|
||||||
const ebayCountryCode = toEbayCountryCode(farmControlCountryCode);
|
|
||||||
if (!ebayCountryCode) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
ebayCountryCode,
|
|
||||||
farmControlCountryCode: farmControlCountryCode.trim().toUpperCase(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,215 +0,0 @@
|
|||||||
import mongoose from 'mongoose';
|
|
||||||
import { courierServiceModel } from '../../../database/schemas/management/courierservice.schema.js';
|
|
||||||
import { makeRequest, logger } from './shared.js';
|
|
||||||
|
|
||||||
const SELLING_POLICY_PROGRAM = 'SELLING_POLICY_MANAGEMENT';
|
|
||||||
|
|
||||||
async function fetchOptedInPrograms(marketplace) {
|
|
||||||
const result = await makeRequest({
|
|
||||||
marketplace,
|
|
||||||
path: '/sell/account/v1/program/get_opted_in_programs',
|
|
||||||
});
|
|
||||||
return result?.programs || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasSellingPolicyManagement(programs) {
|
|
||||||
return programs.some((program) => program?.programType === SELLING_POLICY_PROGRAM);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ensureSellingPolicyManagement(marketplace) {
|
|
||||||
if (hasSellingPolicyManagement(await fetchOptedInPrograms(marketplace))) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info('Opting eBay seller account into Selling Policy Management');
|
|
||||||
await makeRequest({
|
|
||||||
marketplace,
|
|
||||||
method: 'POST',
|
|
||||||
path: '/sell/account/v1/program/opt_in',
|
|
||||||
body: { programType: SELLING_POLICY_PROGRAM },
|
|
||||||
acceptableStatuses: [409],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!hasSellingPolicyManagement(await fetchOptedInPrograms(marketplace))) {
|
|
||||||
throw new Error(
|
|
||||||
'eBay Selling Policy Management enrollment was requested but is not active yet. eBay can take up to 24 hours to process enrollment; retry publishing later.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPopulatedCourierService(service) {
|
|
||||||
return (
|
|
||||||
service &&
|
|
||||||
typeof service === 'object' &&
|
|
||||||
!(service instanceof mongoose.Types.ObjectId) &&
|
|
||||||
service.name != null &&
|
|
||||||
service.courier &&
|
|
||||||
typeof service.courier === 'object' &&
|
|
||||||
!(service.courier instanceof mongoose.Types.ObjectId) &&
|
|
||||||
service.courier._reference
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveCourierServices(listing) {
|
|
||||||
const serviceRefs = listing?.courierServices || [];
|
|
||||||
if (!serviceRefs.length) return [];
|
|
||||||
|
|
||||||
const populatedServices = serviceRefs.filter(isPopulatedCourierService);
|
|
||||||
if (populatedServices.length === serviceRefs.length) {
|
|
||||||
return populatedServices;
|
|
||||||
}
|
|
||||||
|
|
||||||
const serviceIds = serviceRefs.map((service) => service?._id || service).filter(Boolean);
|
|
||||||
const services = await courierServiceModel
|
|
||||||
.find({ _id: { $in: serviceIds } })
|
|
||||||
.populate('courier')
|
|
||||||
.lean();
|
|
||||||
const servicesById = new Map(services.map((service) => [String(service._id), service]));
|
|
||||||
|
|
||||||
return serviceIds.map((id) => servicesById.get(String(id))).filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateCourierServices(services, listing) {
|
|
||||||
const activeServices = services.filter((service) => service.active !== false);
|
|
||||||
if (!activeServices.length) {
|
|
||||||
throw new Error(
|
|
||||||
`Listing "${listing._reference}" must have at least one active courier service before syncing with eBay.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const service of activeServices) {
|
|
||||||
if (!service._reference) {
|
|
||||||
throw new Error(
|
|
||||||
`Courier service "${service.name}" requires a reference containing its eBay shipping service code.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!service.courier?._reference) {
|
|
||||||
throw new Error(
|
|
||||||
`The courier for service "${service.name}" requires a reference containing its eBay shipping carrier code.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const domesticCount = activeServices.filter((service) => !service.international).length;
|
|
||||||
const internationalCount = activeServices.length - domesticCount;
|
|
||||||
if (domesticCount > 4 || internationalCount > 5) {
|
|
||||||
throw new Error(
|
|
||||||
'eBay fulfillment policies support at most four domestic and five international courier services.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return activeServices;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildShippingService(service, index, defaultCurrency) {
|
|
||||||
const cost = Number(service.costWithTax ?? service.cost ?? 0);
|
|
||||||
const additionalCost = Number(service.additionalCostWithTax ?? service.additionalCost ?? 0);
|
|
||||||
const shippingService = {
|
|
||||||
sortOrder: index + 1,
|
|
||||||
shippingCarrierCode: service.courier._reference,
|
|
||||||
shippingServiceCode: service._reference,
|
|
||||||
buyerResponsibleForShipping: false,
|
|
||||||
freeShipping: cost === 0 && additionalCost === 0,
|
|
||||||
shippingCost: {
|
|
||||||
value: String(cost),
|
|
||||||
currency: service.shippingCurrency || defaultCurrency,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
if (service.additionalCostWithTax != null || service.additionalCost != null) {
|
|
||||||
shippingService.additionalShippingCost = {
|
|
||||||
value: String(additionalCost),
|
|
||||||
currency: service.shippingCurrency || defaultCurrency,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return shippingService;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildShippingOption(optionType, services, defaultCurrency) {
|
|
||||||
if (!services.length) return null;
|
|
||||||
return {
|
|
||||||
optionType,
|
|
||||||
costType: 'FLAT_RATE',
|
|
||||||
shippingServices: services.map((service, index) =>
|
|
||||||
buildShippingService(service, index, defaultCurrency)
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildFulfillmentPolicy(listing, marketplace, services) {
|
|
||||||
const marketplaceId = marketplace.config?.marketplaceId || 'EBAY_GB';
|
|
||||||
const defaultCurrency = marketplace.config?.currency || listing.currency || 'GBP';
|
|
||||||
const domesticServices = services.filter((service) => !service.international);
|
|
||||||
const internationalServices = services.filter((service) => service.international);
|
|
||||||
const shippingOptions = [
|
|
||||||
buildShippingOption('DOMESTIC', domesticServices, defaultCurrency),
|
|
||||||
buildShippingOption('INTERNATIONAL', internationalServices, defaultCurrency),
|
|
||||||
].filter(Boolean);
|
|
||||||
const deliveryTime = Math.max(0, ...services.map((service) => Number(service.deliveryTime ?? 1)));
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: `FarmControl ${listing._reference}`.slice(0, 64),
|
|
||||||
description: `Managed by FarmControl for listing ${listing._reference}`.slice(0, 250),
|
|
||||||
marketplaceId,
|
|
||||||
categoryTypes: [{ name: 'ALL_EXCLUDING_MOTORS_VEHICLES' }],
|
|
||||||
handlingTime: { value: deliveryTime, unit: 'DAY' },
|
|
||||||
localPickup: false,
|
|
||||||
globalShipping: false,
|
|
||||||
freightShipping: false,
|
|
||||||
pickupDropOff: false,
|
|
||||||
shippingOptions,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchFulfillmentPolicyByName(marketplace, name) {
|
|
||||||
return makeRequest({
|
|
||||||
marketplace,
|
|
||||||
path: '/sell/account/v1/fulfillment_policy/get_by_policy_name',
|
|
||||||
params: {
|
|
||||||
marketplace_id: marketplace.config?.marketplaceId || 'EBAY_GB',
|
|
||||||
name,
|
|
||||||
},
|
|
||||||
acceptableStatuses: [404],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function syncFulfillmentPolicy(marketplace, listing) {
|
|
||||||
await ensureSellingPolicyManagement(marketplace);
|
|
||||||
|
|
||||||
if (listing?.fulfillmentPolicyId) {
|
|
||||||
return { fulfillmentPolicyId: String(listing.fulfillmentPolicyId) };
|
|
||||||
}
|
|
||||||
|
|
||||||
const services = validateCourierServices(await resolveCourierServices(listing), listing);
|
|
||||||
const policy = buildFulfillmentPolicy(listing, marketplace, services);
|
|
||||||
const existingPolicy = await fetchFulfillmentPolicyByName(marketplace, policy.name);
|
|
||||||
|
|
||||||
if (existingPolicy?.fulfillmentPolicyId) {
|
|
||||||
await makeRequest({
|
|
||||||
marketplace,
|
|
||||||
method: 'PUT',
|
|
||||||
path: `/sell/account/v1/fulfillment_policy/${encodeURIComponent(
|
|
||||||
existingPolicy.fulfillmentPolicyId
|
|
||||||
)}`,
|
|
||||||
body: policy,
|
|
||||||
});
|
|
||||||
logger.info(
|
|
||||||
`Updated eBay fulfillment policy "${policy.name}" (${existingPolicy.fulfillmentPolicyId})`
|
|
||||||
);
|
|
||||||
return { fulfillmentPolicyId: String(existingPolicy.fulfillmentPolicyId) };
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await makeRequest({
|
|
||||||
marketplace,
|
|
||||||
method: 'POST',
|
|
||||||
path: '/sell/account/v1/fulfillment_policy',
|
|
||||||
body: policy,
|
|
||||||
});
|
|
||||||
if (!result?.fulfillmentPolicyId) {
|
|
||||||
throw new Error(`eBay did not return an ID for fulfillment policy "${policy.name}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`Created eBay fulfillment policy "${policy.name}" (${result.fulfillmentPolicyId})`);
|
|
||||||
return { fulfillmentPolicyId: String(result.fulfillmentPolicyId) };
|
|
||||||
}
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
export {
|
|
||||||
createAuthorizationUrl,
|
|
||||||
exchangeAuthorizationCode,
|
|
||||||
refreshAuth,
|
|
||||||
ensureAuthenticatedMarketplace,
|
|
||||||
canVerifyWebhookSignature,
|
|
||||||
verifyWebhookSignature,
|
|
||||||
} from './auth.js';
|
|
||||||
|
|
||||||
export { syncItems, mapProductToListing, createItem, updateItem, deleteItem } from './listings.js';
|
|
||||||
|
|
||||||
export { syncProductCategory } from './categories.js';
|
|
||||||
export { syncFulfillmentPolicy } from './fulfillmentPolicies.js';
|
|
||||||
|
|
||||||
export {
|
|
||||||
publishOfferById,
|
|
||||||
withdrawOfferById,
|
|
||||||
publishOfferForSku,
|
|
||||||
withdrawOfferForSku,
|
|
||||||
resolveStockLocationCountryCode,
|
|
||||||
resolveStockLocationEbayCountry,
|
|
||||||
} from './listingVarients.js';
|
|
||||||
|
|
||||||
export {
|
|
||||||
syncOrders,
|
|
||||||
mapOrderStatus,
|
|
||||||
mapOrderToSalesOrder,
|
|
||||||
mapBuyerToClient,
|
|
||||||
handleWebhook,
|
|
||||||
} from './orders.js';
|
|
||||||
@ -1,396 +0,0 @@
|
|||||||
import mongoose from 'mongoose';
|
|
||||||
import { stockLocationModel } from '../../../database/schemas/inventory/stocklocation.schema.js';
|
|
||||||
import { FARMCONTROL_GB_SUBDIVISION_STATE, resolveEbayCountry } from './countryCodes.js';
|
|
||||||
import { syncProductCategory } from './categories.js';
|
|
||||||
import { syncFulfillmentPolicy } from './fulfillmentPolicies.js';
|
|
||||||
import { makeRequest, logger } from './shared.js';
|
|
||||||
|
|
||||||
const WAREHOUSE_ADDRESS_DEFAULTS = {
|
|
||||||
GB: { city: 'London', stateOrProvince: 'England', postalCode: 'SW1A 1AA' },
|
|
||||||
US: { city: 'New York', stateOrProvince: 'NY', postalCode: '10001' },
|
|
||||||
AU: { city: 'Sydney', stateOrProvince: 'NSW', postalCode: '2000' },
|
|
||||||
CA: { city: 'Toronto', stateOrProvince: 'ON', postalCode: 'M5H 2N2' },
|
|
||||||
DE: { city: 'Berlin', stateOrProvince: 'Berlin', postalCode: '10115' },
|
|
||||||
FR: { city: 'Paris', stateOrProvince: 'Île-de-France', postalCode: '75001' },
|
|
||||||
};
|
|
||||||
|
|
||||||
const LISTING_STATUS_MAP = {
|
|
||||||
ACTIVE: 'active',
|
|
||||||
OUT_OF_STOCK: 'inactive',
|
|
||||||
ENDED: 'inactive',
|
|
||||||
PUBLISHED: 'active',
|
|
||||||
UNPUBLISHED: 'draft',
|
|
||||||
};
|
|
||||||
|
|
||||||
function isPopulatedStockLocation(stockLocation) {
|
|
||||||
if (!stockLocation || typeof stockLocation !== 'object') return false;
|
|
||||||
if (stockLocation instanceof mongoose.Types.ObjectId) return false;
|
|
||||||
return stockLocation.name != null || stockLocation.address != null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ensureListingStockLocation(listing) {
|
|
||||||
const stockLocationRef = listing?.stockLocation;
|
|
||||||
if (!stockLocationRef || isPopulatedStockLocation(stockLocationRef)) {
|
|
||||||
return listing;
|
|
||||||
}
|
|
||||||
|
|
||||||
const stockLocationId = stockLocationRef._id || stockLocationRef;
|
|
||||||
const stockLocation = await stockLocationModel.findById(stockLocationId).lean();
|
|
||||||
if (!stockLocation) throw new Error('Listing stock location not found.');
|
|
||||||
return { ...listing, stockLocation };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveStockLocationCountryCode(listing) {
|
|
||||||
return resolveStockLocationEbayCountry(listing)?.ebayCountryCode ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveStockLocationEbayCountry(listing) {
|
|
||||||
const stockLocation = listing?.stockLocation;
|
|
||||||
if (!isPopulatedStockLocation(stockLocation)) return null;
|
|
||||||
try {
|
|
||||||
return resolveEbayCountry(stockLocation.address?.country);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveMerchantLocationKey(listing) {
|
|
||||||
const stockLocationId = listing?.stockLocation?._id || listing?.stockLocation;
|
|
||||||
return stockLocationId ? `fc-${String(stockLocationId)}`.slice(0, 36) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildEbayWarehouseAddress(addr, ebayCountryCode, farmControlCountryCode) {
|
|
||||||
const address = { country: ebayCountryCode };
|
|
||||||
|
|
||||||
if (addr?.addressLine1) address.addressLine1 = addr.addressLine1;
|
|
||||||
if (addr?.addressLine2) address.addressLine2 = addr.addressLine2;
|
|
||||||
if (addr?.city) address.city = addr.city;
|
|
||||||
if (addr?.state) address.stateOrProvince = addr.state;
|
|
||||||
if (addr?.postcode) address.postalCode = addr.postcode;
|
|
||||||
|
|
||||||
if (
|
|
||||||
!address.stateOrProvince &&
|
|
||||||
farmControlCountryCode &&
|
|
||||||
FARMCONTROL_GB_SUBDIVISION_STATE[farmControlCountryCode]
|
|
||||||
) {
|
|
||||||
address.stateOrProvince = FARMCONTROL_GB_SUBDIVISION_STATE[farmControlCountryCode];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!address.postalCode && !(address.city && address.stateOrProvince)) {
|
|
||||||
const defaults = WAREHOUSE_ADDRESS_DEFAULTS[ebayCountryCode];
|
|
||||||
if (defaults) {
|
|
||||||
Object.assign(address, defaults);
|
|
||||||
if (farmControlCountryCode && FARMCONTROL_GB_SUBDIVISION_STATE[farmControlCountryCode]) {
|
|
||||||
address.stateOrProvince = FARMCONTROL_GB_SUBDIVISION_STATE[farmControlCountryCode];
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
address.city = address.city || 'Unknown';
|
|
||||||
address.stateOrProvince = address.stateOrProvince || ebayCountryCode;
|
|
||||||
address.postalCode = address.postalCode || '00000';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!address.country) {
|
|
||||||
throw new Error('eBay warehouse address requires a country code.');
|
|
||||||
}
|
|
||||||
return address;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildWarehouseLocationBody(listing, ebayCountryCode, farmControlCountryCode) {
|
|
||||||
const stockLocation = listing.stockLocation;
|
|
||||||
return {
|
|
||||||
location: {
|
|
||||||
address: buildEbayWarehouseAddress(
|
|
||||||
stockLocation?.address || {},
|
|
||||||
ebayCountryCode,
|
|
||||||
farmControlCountryCode
|
|
||||||
),
|
|
||||||
},
|
|
||||||
locationTypes: ['WAREHOUSE'],
|
|
||||||
name: stockLocation?.name || `FarmControl ${ebayCountryCode}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function ensureMerchantLocation(marketplace, listing) {
|
|
||||||
const listingWithStockLocation = await ensureListingStockLocation(listing);
|
|
||||||
const countryResolved = resolveEbayCountry(
|
|
||||||
listingWithStockLocation.stockLocation?.address?.country
|
|
||||||
);
|
|
||||||
if (!countryResolved) {
|
|
||||||
throw new Error(
|
|
||||||
'Listing stock location address must include a country before syncing or publishing on eBay.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const merchantLocationKey = resolveMerchantLocationKey(listingWithStockLocation);
|
|
||||||
if (!merchantLocationKey) {
|
|
||||||
throw new Error('Listing must have a stock location before syncing or publishing on eBay.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const locationBody = buildWarehouseLocationBody(
|
|
||||||
listingWithStockLocation,
|
|
||||||
countryResolved.ebayCountryCode,
|
|
||||||
countryResolved.farmControlCountryCode
|
|
||||||
);
|
|
||||||
listing.stockLocation = listingWithStockLocation.stockLocation;
|
|
||||||
const locationPath = `/sell/inventory/v1/location/${encodeURIComponent(merchantLocationKey)}`;
|
|
||||||
const existing = await makeRequest({
|
|
||||||
marketplace,
|
|
||||||
path: locationPath,
|
|
||||||
acceptableStatuses: [404],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
await makeRequest({
|
|
||||||
marketplace,
|
|
||||||
method: 'POST',
|
|
||||||
path: `${locationPath}/update_location_details`,
|
|
||||||
body: { location: locationBody.location },
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await makeRequest({
|
|
||||||
marketplace,
|
|
||||||
method: 'POST',
|
|
||||||
path: locationPath,
|
|
||||||
body: locationBody,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return merchantLocationKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyMarketplaceOfferDefaults(
|
|
||||||
offer,
|
|
||||||
marketplace,
|
|
||||||
merchantLocationKey,
|
|
||||||
fulfillmentPolicyId
|
|
||||||
) {
|
|
||||||
offer.merchantLocationKey = merchantLocationKey;
|
|
||||||
const config = marketplace.config || {};
|
|
||||||
const listingPolicies = {};
|
|
||||||
if (fulfillmentPolicyId || config.fulfillmentPolicyId) {
|
|
||||||
listingPolicies.fulfillmentPolicyId = fulfillmentPolicyId || config.fulfillmentPolicyId;
|
|
||||||
}
|
|
||||||
if (config.paymentPolicyId) listingPolicies.paymentPolicyId = config.paymentPolicyId;
|
|
||||||
if (config.returnPolicyId) listingPolicies.returnPolicyId = config.returnPolicyId;
|
|
||||||
if (Object.keys(listingPolicies).length) offer.listingPolicies = listingPolicies;
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapVarientToInventoryItem(varient, listing) {
|
|
||||||
const item = {
|
|
||||||
product: { title: listing.title || varient._reference || '' },
|
|
||||||
availability: {
|
|
||||||
shipToLocationAvailability: { quantity: varient.inventory ?? 0 },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
if (listing.description) item.product.description = listing.description;
|
|
||||||
if (listing.imageUrls?.length) item.product.imageUrls = listing.imageUrls;
|
|
||||||
return item;
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapVarientToOffer(varient, listing, marketplace, merchantLocationKey) {
|
|
||||||
const offer = {
|
|
||||||
sku: varient._reference,
|
|
||||||
marketplaceId: marketplace.config?.marketplaceId || 'EBAY_GB',
|
|
||||||
format: 'FIXED_PRICE',
|
|
||||||
};
|
|
||||||
applyMarketplaceOfferDefaults(
|
|
||||||
offer,
|
|
||||||
marketplace,
|
|
||||||
merchantLocationKey,
|
|
||||||
listing.fulfillmentPolicyId
|
|
||||||
);
|
|
||||||
|
|
||||||
const price = varient.price ?? listing.price;
|
|
||||||
if (price != null) {
|
|
||||||
offer.pricingSummary = {
|
|
||||||
price: {
|
|
||||||
value: String(price),
|
|
||||||
currency: varient.currency || listing.currency || 'GBP',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (listing.categoryId) offer.categoryId = String(listing.categoryId);
|
|
||||||
return offer;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function upsertInventoryItem(marketplace, varient, listing) {
|
|
||||||
const inventoryItem = mapVarientToInventoryItem(varient, listing);
|
|
||||||
const result = await makeRequest({
|
|
||||||
marketplace,
|
|
||||||
method: 'PUT',
|
|
||||||
path: `/sell/inventory/v1/inventory_item/${encodeURIComponent(varient._reference)}`,
|
|
||||||
body: inventoryItem,
|
|
||||||
});
|
|
||||||
logger.debug('inventoryItem', inventoryItem);
|
|
||||||
logger.debug('result', result);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchOffers(marketplace, sku) {
|
|
||||||
try {
|
|
||||||
const data = await makeRequest({
|
|
||||||
marketplace,
|
|
||||||
path: '/sell/inventory/v1/offer',
|
|
||||||
params: { sku, limit: 200 },
|
|
||||||
acceptableStatuses: [404],
|
|
||||||
});
|
|
||||||
return data?.offers || [];
|
|
||||||
} catch (err) {
|
|
||||||
logger.debug(`No offers found for SKU ${sku}: ${err.message}`);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function upsertOrCreateOffer(marketplace, varient, listing) {
|
|
||||||
const merchantLocationKey = await ensureMerchantLocation(marketplace, listing);
|
|
||||||
const offers = await fetchOffers(marketplace, varient._reference);
|
|
||||||
const existingOffer = offers[0];
|
|
||||||
const price = varient.price ?? listing.price;
|
|
||||||
|
|
||||||
if (existingOffer?.offerId) {
|
|
||||||
const offerUpdate = { merchantLocationKey };
|
|
||||||
if (price != null) {
|
|
||||||
offerUpdate.pricingSummary = {
|
|
||||||
price: {
|
|
||||||
value: String(price),
|
|
||||||
currency: varient.currency || listing.currency || 'GBP',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (listing.categoryId) offerUpdate.categoryId = String(listing.categoryId);
|
|
||||||
if (listing.fulfillmentPolicyId) {
|
|
||||||
offerUpdate.listingPolicies = {
|
|
||||||
...(existingOffer.listingPolicies || {}),
|
|
||||||
fulfillmentPolicyId: String(listing.fulfillmentPolicyId),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const body = { ...existingOffer, ...offerUpdate };
|
|
||||||
await makeRequest({
|
|
||||||
marketplace,
|
|
||||||
method: 'PUT',
|
|
||||||
path: `/sell/inventory/v1/offer/${existingOffer.offerId}`,
|
|
||||||
body,
|
|
||||||
});
|
|
||||||
logger.debug('offerUpdate', body);
|
|
||||||
return existingOffer;
|
|
||||||
}
|
|
||||||
|
|
||||||
const offerBody = mapVarientToOffer(varient, listing, marketplace, merchantLocationKey);
|
|
||||||
const result = await makeRequest({
|
|
||||||
marketplace,
|
|
||||||
method: 'POST',
|
|
||||||
path: '/sell/inventory/v1/offer',
|
|
||||||
body: offerBody,
|
|
||||||
});
|
|
||||||
logger.debug('offerBody', offerBody);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function publishOfferById(marketplace, offerId) {
|
|
||||||
if (!offerId) throw new Error('offerId is required to publish an offer');
|
|
||||||
return makeRequest({
|
|
||||||
marketplace,
|
|
||||||
method: 'POST',
|
|
||||||
path: `/sell/inventory/v1/offer/${encodeURIComponent(offerId)}/publish`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function withdrawOfferById(marketplace, offerId) {
|
|
||||||
if (!offerId) throw new Error('offerId is required to withdraw an offer');
|
|
||||||
return makeRequest({
|
|
||||||
marketplace,
|
|
||||||
method: 'POST',
|
|
||||||
path: `/sell/inventory/v1/offer/${encodeURIComponent(offerId)}/withdraw`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function syncOfferAndMaybePublish(marketplace, listing, varient) {
|
|
||||||
const offerResult = await upsertOrCreateOffer(marketplace, varient, listing);
|
|
||||||
if (offerResult?.offerId && listing.state?.type === 'active') {
|
|
||||||
try {
|
|
||||||
const publishResult = await publishOfferById(marketplace, offerResult.offerId);
|
|
||||||
if (publishResult?.listingId) {
|
|
||||||
return `https://www.ebay.com/itm/${publishResult.listingId}`;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn(
|
|
||||||
`Created offer but failed to publish for varient ${varient._reference}: ${err.message}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function publishOfferForSku(marketplace, sku, listing) {
|
|
||||||
if (!sku) throw new Error('SKU (_reference) is required to publish an offer');
|
|
||||||
if (!listing) {
|
|
||||||
throw new Error(
|
|
||||||
'Listing is required to publish an eBay offer (stock location address is used for item location).'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const merchantLocationKey = await ensureMerchantLocation(marketplace, listing);
|
|
||||||
const category = await syncProductCategory(marketplace, listing);
|
|
||||||
const fulfillmentPolicy = await syncFulfillmentPolicy(marketplace, listing);
|
|
||||||
if (!category?.categoryId) {
|
|
||||||
throw new Error(
|
|
||||||
`Listing "${listing._reference || sku}" must have a product with a product category before publishing on eBay.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingOffer = (await fetchOffers(marketplace, sku))[0];
|
|
||||||
if (!existingOffer?.offerId) {
|
|
||||||
throw new Error(
|
|
||||||
`No eBay offer exists for SKU "${sku}". Create or sync the listing so an offer exists before publishing.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await makeRequest({
|
|
||||||
marketplace,
|
|
||||||
method: 'PUT',
|
|
||||||
path: `/sell/inventory/v1/offer/${existingOffer.offerId}`,
|
|
||||||
body: {
|
|
||||||
...existingOffer,
|
|
||||||
merchantLocationKey,
|
|
||||||
categoryId: String(category.categoryId),
|
|
||||||
listingPolicies: {
|
|
||||||
...(existingOffer.listingPolicies || {}),
|
|
||||||
fulfillmentPolicyId: fulfillmentPolicy.fulfillmentPolicyId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const publishResult = await publishOfferById(marketplace, existingOffer.offerId);
|
|
||||||
return { offerId: existingOffer.offerId, listingId: publishResult?.listingId };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function withdrawOfferForSku(marketplace, sku) {
|
|
||||||
if (!sku) throw new Error('SKU (_reference) is required to withdraw an offer');
|
|
||||||
const existingOffer = (await fetchOffers(marketplace, sku))[0];
|
|
||||||
if (!existingOffer?.offerId) throw new Error(`No eBay offer exists for SKU "${sku}".`);
|
|
||||||
await withdrawOfferById(marketplace, existingOffer.offerId);
|
|
||||||
return { offerId: existingOffer.offerId };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveOfferState(offers) {
|
|
||||||
let stateType = 'draft';
|
|
||||||
for (const offer of offers) {
|
|
||||||
if (offer?.status && LISTING_STATUS_MAP[offer.status]) {
|
|
||||||
const mapped = LISTING_STATUS_MAP[offer.status];
|
|
||||||
if (mapped === 'active') return 'active';
|
|
||||||
if (mapped !== 'draft') stateType = mapped;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return stateType;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildVarientEntry(item, offers) {
|
|
||||||
const offer = offers?.[0];
|
|
||||||
return {
|
|
||||||
_reference: item.sku,
|
|
||||||
price: offer?.pricingSummary?.price?.value
|
|
||||||
? parseFloat(offer.pricingSummary.price.value)
|
|
||||||
: undefined,
|
|
||||||
currency: offer?.pricingSummary?.price?.currency || undefined,
|
|
||||||
state: { type: resolveOfferState(offers || []) },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,312 +0,0 @@
|
|||||||
import { syncProductCategory } from './categories.js';
|
|
||||||
import { syncFulfillmentPolicy } from './fulfillmentPolicies.js';
|
|
||||||
import {
|
|
||||||
buildVarientEntry,
|
|
||||||
fetchOffers,
|
|
||||||
resolveOfferState,
|
|
||||||
syncOfferAndMaybePublish,
|
|
||||||
upsertInventoryItem,
|
|
||||||
} from './listingVarients.js';
|
|
||||||
import { makeRequest, logger } from './shared.js';
|
|
||||||
|
|
||||||
function sleep(ms) {
|
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createOrReplaceGroup(marketplace, listing, varients) {
|
|
||||||
const groupKey = listing._reference;
|
|
||||||
const variantSKUs = varients.map((v) => v._reference).filter(Boolean);
|
|
||||||
|
|
||||||
const body = {
|
|
||||||
title: listing.title || groupKey,
|
|
||||||
variantSKUs,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (listing.description) {
|
|
||||||
body.description = listing.description;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (listing.imageUrls?.length) {
|
|
||||||
body.imageUrls = listing.imageUrls;
|
|
||||||
}
|
|
||||||
|
|
||||||
await makeRequest({
|
|
||||||
marketplace,
|
|
||||||
method: 'PUT',
|
|
||||||
path: `/sell/inventory/v1/inventory_item_group/${encodeURIComponent(groupKey)}`,
|
|
||||||
body,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteGroup(marketplace, groupKey) {
|
|
||||||
try {
|
|
||||||
await makeRequest({
|
|
||||||
marketplace,
|
|
||||||
method: 'DELETE',
|
|
||||||
path: `/sell/inventory/v1/inventory_item_group/${encodeURIComponent(groupKey)}`,
|
|
||||||
acceptableStatuses: [404],
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn(`Failed to delete inventory item group "${groupKey}": ${err.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function syncSingleVarientListing(marketplace, listing, varient) {
|
|
||||||
await upsertInventoryItem(marketplace, varient, listing);
|
|
||||||
|
|
||||||
// If this listing used to be grouped, remove the stale group before treating it as a standalone item.
|
|
||||||
if (listing._reference) {
|
|
||||||
const existingGroup = await safeFetchInventoryItemGroup(marketplace, listing._reference);
|
|
||||||
if (existingGroup) {
|
|
||||||
await deleteGroup(marketplace, listing._reference);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return syncOfferAndMaybePublish(marketplace, listing, varient);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function syncGroupedListing(marketplace, listing, varients) {
|
|
||||||
logger.info(
|
|
||||||
`Syncing eBay inventory item group "${listing._reference}" with ${varients.length} varient(s)`
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const varient of varients) {
|
|
||||||
await upsertInventoryItem(marketplace, varient, listing);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Brief delay so eBay can resolve the new inventory item SKUs before creating the group.
|
|
||||||
await sleep(1000);
|
|
||||||
await createOrReplaceGroup(marketplace, listing, varients);
|
|
||||||
|
|
||||||
let firstPublishedUrl = '';
|
|
||||||
for (const varient of varients) {
|
|
||||||
try {
|
|
||||||
const publishedUrl = await syncOfferAndMaybePublish(marketplace, listing, varient);
|
|
||||||
if (publishedUrl && !firstPublishedUrl) firstPublishedUrl = publishedUrl;
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn(`Failed to create offer for varient ${varient._reference}: ${err.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return firstPublishedUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function syncListing(marketplace, listing, varients, actionLabel) {
|
|
||||||
const ref = listing._reference;
|
|
||||||
if (!ref) {
|
|
||||||
throw new Error(`Listing must have a _reference to ${actionLabel} on eBay`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const validVarients = (varients || []).filter((varient) => varient?._reference);
|
|
||||||
if (validVarients.length === 0) {
|
|
||||||
throw new Error(
|
|
||||||
`Listing must have at least one varient with a _reference to ${actionLabel} on eBay`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const category = await syncProductCategory(marketplace, listing, validVarients);
|
|
||||||
const fulfillmentPolicy = await syncFulfillmentPolicy(marketplace, listing);
|
|
||||||
const listingWithContext = {
|
|
||||||
...listing,
|
|
||||||
...(category ? { categoryId: category.categoryId } : {}),
|
|
||||||
fulfillmentPolicyId: fulfillmentPolicy.fulfillmentPolicyId,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (validVarients.length === 1) {
|
|
||||||
logger.info(
|
|
||||||
`Syncing standalone eBay inventory item "${validVarients[0]._reference}" for listing "${ref}"`
|
|
||||||
);
|
|
||||||
const url = await syncSingleVarientListing(marketplace, listingWithContext, validVarients[0]);
|
|
||||||
return { url };
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = await syncGroupedListing(marketplace, listingWithContext, validVarients);
|
|
||||||
return { url };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createItem(marketplace, listing, varients) {
|
|
||||||
return syncListing(marketplace, listing, varients, 'create');
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateItem(marketplace, listing, varients) {
|
|
||||||
return syncListing(marketplace, listing, varients, 'update');
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteItem(marketplace, listing) {
|
|
||||||
const ref = listing._reference;
|
|
||||||
if (!ref) return;
|
|
||||||
|
|
||||||
logger.info(`Deleting eBay inventory item group "${ref}"`);
|
|
||||||
await deleteGroup(marketplace, ref);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Sync helpers (inbound from eBay) ---
|
|
||||||
|
|
||||||
async function fetchAllInventoryItems(marketplace) {
|
|
||||||
const items = [];
|
|
||||||
let offset = 0;
|
|
||||||
const limit = 100;
|
|
||||||
|
|
||||||
do {
|
|
||||||
const data = await makeRequest({
|
|
||||||
marketplace,
|
|
||||||
path: '/sell/inventory/v1/inventory_item',
|
|
||||||
params: { limit, offset },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (data?.inventoryItems?.length) {
|
|
||||||
items.push(...data.inventoryItems);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!data?.inventoryItems?.length || items.length >= (data.total || 0)) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
offset += limit;
|
|
||||||
} while (true);
|
|
||||||
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function safeFetchInventoryItemGroup(marketplace, groupKey) {
|
|
||||||
try {
|
|
||||||
return await makeRequest({
|
|
||||||
marketplace,
|
|
||||||
path: `/sell/inventory/v1/inventory_item_group/${encodeURIComponent(groupKey)}`,
|
|
||||||
acceptableStatuses: [404],
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn(`Failed to fetch inventory item group "${groupKey}": ${err.message}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function syncItems(marketplace) {
|
|
||||||
logger.info(`Syncing inventory from eBay marketplace: ${marketplace.name}`);
|
|
||||||
|
|
||||||
const inventoryItems = await fetchAllInventoryItems(marketplace);
|
|
||||||
const itemsBySku = new Map();
|
|
||||||
const groupKeysSet = new Set();
|
|
||||||
const groupedSkus = new Set();
|
|
||||||
|
|
||||||
for (const item of inventoryItems) {
|
|
||||||
itemsBySku.set(item.sku, item);
|
|
||||||
if (item.inventoryItemGroupKeys?.length) {
|
|
||||||
for (const key of item.inventoryItemGroupKeys) {
|
|
||||||
groupKeysSet.add(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const results = [];
|
|
||||||
|
|
||||||
for (const groupKey of groupKeysSet) {
|
|
||||||
const group = await safeFetchInventoryItemGroup(marketplace, groupKey);
|
|
||||||
if (!group) continue;
|
|
||||||
|
|
||||||
const variantSkus = group.variantSKUs || [];
|
|
||||||
for (const sku of variantSkus) {
|
|
||||||
groupedSkus.add(sku);
|
|
||||||
}
|
|
||||||
|
|
||||||
const variantItems = [];
|
|
||||||
for (const sku of variantSkus) {
|
|
||||||
const item = itemsBySku.get(sku);
|
|
||||||
if (item) {
|
|
||||||
try {
|
|
||||||
const offers = await fetchOffers(marketplace, sku);
|
|
||||||
variantItems.push({ ...item, _offers: offers });
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn(`Failed to fetch offers for group variant SKU ${sku}: ${err.message}`);
|
|
||||||
variantItems.push({ ...item, _offers: [] });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
results.push({
|
|
||||||
_type: 'group',
|
|
||||||
_groupKey: groupKey,
|
|
||||||
_group: group,
|
|
||||||
_variants: variantItems,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const item of inventoryItems) {
|
|
||||||
if (groupedSkus.has(item.sku)) continue;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const offers = await fetchOffers(marketplace, item.sku);
|
|
||||||
results.push({
|
|
||||||
_type: 'single',
|
|
||||||
...item,
|
|
||||||
_offers: offers,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn(`Failed to fetch offers for SKU ${item.sku}: ${err.message}`);
|
|
||||||
results.push({
|
|
||||||
_type: 'single',
|
|
||||||
...item,
|
|
||||||
_offers: [],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
`Fetched ${results.length} listing(s) from eBay (${groupKeysSet.size} group(s), ${results.length - groupKeysSet.size} standalone)`
|
|
||||||
);
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mapProductToListing(ebayItem) {
|
|
||||||
if (ebayItem._type === 'group') {
|
|
||||||
const group = ebayItem._group;
|
|
||||||
const variants = ebayItem._variants || [];
|
|
||||||
const allOffers = variants.flatMap((v) => v._offers || []);
|
|
||||||
|
|
||||||
const stateType = resolveOfferState(allOffers);
|
|
||||||
const firstPublishedOffer = allOffers.find((o) => o?.listingId);
|
|
||||||
const url = firstPublishedOffer?.listingId
|
|
||||||
? `https://www.ebay.com/itm/${firstPublishedOffer.listingId}`
|
|
||||||
: '';
|
|
||||||
|
|
||||||
const firstOffer = allOffers[0];
|
|
||||||
const price = firstOffer?.pricingSummary?.price?.value
|
|
||||||
? parseFloat(firstOffer.pricingSummary.price.value)
|
|
||||||
: undefined;
|
|
||||||
const currency = firstOffer?.pricingSummary?.price?.currency || undefined;
|
|
||||||
|
|
||||||
const varients = variants.map((v) => buildVarientEntry(v, v._offers || []));
|
|
||||||
|
|
||||||
return {
|
|
||||||
_reference: ebayItem._groupKey,
|
|
||||||
title: group.title || ebayItem._groupKey,
|
|
||||||
state: { type: stateType },
|
|
||||||
price,
|
|
||||||
currency,
|
|
||||||
url,
|
|
||||||
varients,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const offer = ebayItem._offers?.[0];
|
|
||||||
const price = offer?.pricingSummary?.price?.value
|
|
||||||
? parseFloat(offer.pricingSummary.price.value)
|
|
||||||
: undefined;
|
|
||||||
const currency = offer?.pricingSummary?.price?.currency || undefined;
|
|
||||||
|
|
||||||
const stateType = resolveOfferState(ebayItem._offers || []);
|
|
||||||
|
|
||||||
const url = offer?.listingId ? `https://www.ebay.com/itm/${offer.listingId}` : '';
|
|
||||||
|
|
||||||
const varients = [buildVarientEntry(ebayItem, ebayItem._offers || [])];
|
|
||||||
|
|
||||||
return {
|
|
||||||
_reference: ebayItem.sku,
|
|
||||||
title: ebayItem.product?.title || ebayItem.sku,
|
|
||||||
state: { type: stateType },
|
|
||||||
price,
|
|
||||||
currency,
|
|
||||||
url,
|
|
||||||
varients,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,156 +0,0 @@
|
|||||||
import { makeRequest, logger } from './shared.js';
|
|
||||||
|
|
||||||
async function fetchAllOrders(marketplace, { startTime, endTime } = {}) {
|
|
||||||
const orders = [];
|
|
||||||
let offset = 0;
|
|
||||||
const limit = 50;
|
|
||||||
|
|
||||||
const filterParts = [];
|
|
||||||
if (startTime) {
|
|
||||||
const isoStart = new Date(startTime * 1000).toISOString();
|
|
||||||
filterParts.push(`creationdate:[${isoStart}..`);
|
|
||||||
}
|
|
||||||
if (endTime) {
|
|
||||||
const isoEnd = new Date(endTime * 1000).toISOString();
|
|
||||||
if (filterParts.length && filterParts[0].startsWith('creationdate:')) {
|
|
||||||
filterParts[0] = filterParts[0] + `${isoEnd}]`;
|
|
||||||
} else {
|
|
||||||
filterParts.push(`creationdate:[..${isoEnd}]`);
|
|
||||||
}
|
|
||||||
} else if (filterParts.length) {
|
|
||||||
filterParts[0] = filterParts[0] + ']';
|
|
||||||
}
|
|
||||||
|
|
||||||
do {
|
|
||||||
const params = { limit, offset };
|
|
||||||
if (filterParts.length) {
|
|
||||||
params.filter = filterParts.join(',');
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await makeRequest({
|
|
||||||
marketplace,
|
|
||||||
path: '/sell/fulfillment/v1/order',
|
|
||||||
params,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (data?.orders?.length) {
|
|
||||||
orders.push(...data.orders);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!data?.orders?.length || orders.length >= (data.total || 0)) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
offset += limit;
|
|
||||||
} while (true);
|
|
||||||
|
|
||||||
return orders;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function syncOrders(marketplace, { startTime, endTime } = {}) {
|
|
||||||
logger.info(`Syncing orders from eBay marketplace: ${marketplace.name}`);
|
|
||||||
|
|
||||||
const orders = await fetchAllOrders(marketplace, { startTime, endTime });
|
|
||||||
|
|
||||||
logger.info(`Fetched ${orders.length} order(s) from eBay`);
|
|
||||||
return orders;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ORDER_STATUS_MAP = {
|
|
||||||
NOT_STARTED: 'draft',
|
|
||||||
IN_PROGRESS: 'confirmed',
|
|
||||||
FULFILLED: 'shipped',
|
|
||||||
CANCELLED: 'cancelled',
|
|
||||||
};
|
|
||||||
|
|
||||||
const FULFILLMENT_STATUS_MAP = {
|
|
||||||
NOT_STARTED: 'confirmed',
|
|
||||||
IN_PROGRESS: 'shipped',
|
|
||||||
FULFILLED: 'delivered',
|
|
||||||
};
|
|
||||||
|
|
||||||
export function mapOrderStatus(ebayOrder) {
|
|
||||||
if (ebayOrder.cancelStatus?.cancelState === 'CANCELED') {
|
|
||||||
return 'cancelled';
|
|
||||||
}
|
|
||||||
|
|
||||||
const fulfillmentStatus = ebayOrder.fulfillmentStartInstructions?.[0]?.fulfillmentStatus;
|
|
||||||
if (fulfillmentStatus && FULFILLMENT_STATUS_MAP[fulfillmentStatus]) {
|
|
||||||
return FULFILLMENT_STATUS_MAP[fulfillmentStatus];
|
|
||||||
}
|
|
||||||
|
|
||||||
return ORDER_STATUS_MAP[ebayOrder.orderFulfillmentStatus] || 'draft';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mapOrderToSalesOrder(ebayOrder) {
|
|
||||||
const pricingSummary = ebayOrder.pricingSummary || {};
|
|
||||||
const totalAmount = parseFloat(pricingSummary.priceSubtotal?.value || 0);
|
|
||||||
const shippingAmount = parseFloat(pricingSummary.deliveryCost?.value || 0);
|
|
||||||
const totalTax = parseFloat(pricingSummary.tax?.value || 0);
|
|
||||||
const grandTotal = parseFloat(pricingSummary.total?.value || 0);
|
|
||||||
|
|
||||||
return {
|
|
||||||
externalId: ebayOrder.orderId,
|
|
||||||
state: { type: mapOrderStatus(ebayOrder) },
|
|
||||||
totalAmount,
|
|
||||||
totalAmountWithTax: totalAmount + totalTax,
|
|
||||||
shippingAmount,
|
|
||||||
shippingAmountWithTax: shippingAmount,
|
|
||||||
grandTotalAmount: grandTotal,
|
|
||||||
totalTaxAmount: totalTax,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mapBuyerToClient(ebayOrder) {
|
|
||||||
const buyer = ebayOrder.buyer || {};
|
|
||||||
const address = ebayOrder.fulfillmentStartInstructions?.[0]?.shippingStep?.shipTo || {};
|
|
||||||
|
|
||||||
const fullName = address.fullName || buyer.username || 'Unknown';
|
|
||||||
const contactAddress = address.contactAddress || {};
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: fullName,
|
|
||||||
email: buyer.buyerRegistrationAddress?.email || '',
|
|
||||||
phone: address.primaryPhone?.phoneNumber || '',
|
|
||||||
address: {
|
|
||||||
addressLine1: contactAddress.addressLine1 || '',
|
|
||||||
addressLine2: contactAddress.addressLine2 || '',
|
|
||||||
city: contactAddress.city || '',
|
|
||||||
state: contactAddress.stateOrProvince || '',
|
|
||||||
postcode: contactAddress.postalCode || '',
|
|
||||||
country: contactAddress.countryCode || '',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function handleWebhook(marketplace, event) {
|
|
||||||
const { topic, data } = event;
|
|
||||||
|
|
||||||
logger.info(`eBay webhook received: ${topic} for marketplace ${marketplace.name}`);
|
|
||||||
|
|
||||||
if (topic?.startsWith('marketplace.account_deletion')) {
|
|
||||||
return { action: 'accountDeletion', userId: data?.userId };
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (topic) {
|
|
||||||
case 'item.sold':
|
|
||||||
return { action: 'orderCreate', orderId: data?.orderId };
|
|
||||||
|
|
||||||
case 'item.created':
|
|
||||||
case 'item.updated':
|
|
||||||
return { action: 'productUpdate', itemId: data?.itemId };
|
|
||||||
|
|
||||||
case 'item.ended':
|
|
||||||
return { action: 'productEnded', itemId: data?.itemId };
|
|
||||||
|
|
||||||
case 'order.cancelled':
|
|
||||||
return { action: 'orderCancel', orderId: data?.orderId };
|
|
||||||
|
|
||||||
case 'order.fulfillment':
|
|
||||||
return { action: 'orderUpdate', orderId: data?.orderId };
|
|
||||||
|
|
||||||
default:
|
|
||||||
logger.debug(`Unhandled eBay webhook topic: ${topic}`);
|
|
||||||
return { action: 'unknown', topic };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,181 +0,0 @@
|
|||||||
import config from '../../../config.js';
|
|
||||||
import log4js from 'log4js';
|
|
||||||
|
|
||||||
const logger = log4js.getLogger('eBay');
|
|
||||||
logger.level = config.server.logLevel;
|
|
||||||
|
|
||||||
const SANDBOX_API_URL = 'https://api.sandbox.ebay.com';
|
|
||||||
const PRODUCTION_API_URL = 'https://api.ebay.com';
|
|
||||||
const SANDBOX_AUTH_URL = 'https://auth.sandbox.ebay.com';
|
|
||||||
const PRODUCTION_AUTH_URL = 'https://auth.ebay.com';
|
|
||||||
const TOKEN_PATH = '/identity/v1/oauth2/token';
|
|
||||||
const DEFAULT_SCOPES = [
|
|
||||||
'https://api.ebay.com/oauth/api_scope',
|
|
||||||
'https://api.ebay.com/oauth/api_scope/sell.inventory',
|
|
||||||
'https://api.ebay.com/oauth/api_scope/sell.fulfillment',
|
|
||||||
'https://api.ebay.com/oauth/api_scope/sell.account',
|
|
||||||
];
|
|
||||||
const MARKETPLACE_LANGUAGE_MAP = {
|
|
||||||
EBAY_US: 'en-US',
|
|
||||||
EBAY_GB: 'en-GB',
|
|
||||||
EBAY_AU: 'en-AU',
|
|
||||||
EBAY_CA: 'en-CA',
|
|
||||||
EBAY_DE: 'de-DE',
|
|
||||||
EBAY_FR: 'fr-FR',
|
|
||||||
EBAY_ES: 'es-ES',
|
|
||||||
EBAY_IT: 'it-IT',
|
|
||||||
EBAY_NL: 'nl-NL',
|
|
||||||
EBAY_BE: 'nl-BE',
|
|
||||||
};
|
|
||||||
|
|
||||||
export function getApiBaseUrl(marketplace) {
|
|
||||||
return marketplace.config.sandbox ? SANDBOX_API_URL : PRODUCTION_API_URL;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getAuthorizeBaseUrl(marketplace) {
|
|
||||||
return marketplace.config.sandbox ? SANDBOX_AUTH_URL : PRODUCTION_AUTH_URL;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getScopes(marketplace) {
|
|
||||||
if (Array.isArray(marketplace.config.scopes) && marketplace.config.scopes.length) {
|
|
||||||
return marketplace.config.scopes;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof marketplace.config.scopes === 'string' && marketplace.config.scopes.trim()) {
|
|
||||||
return marketplace.config.scopes.trim().split(/\s+/);
|
|
||||||
}
|
|
||||||
|
|
||||||
return DEFAULT_SCOPES;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getScopesString(marketplace) {
|
|
||||||
return getScopes(marketplace).join(' ');
|
|
||||||
}
|
|
||||||
|
|
||||||
function isValidLanguageTag(value) {
|
|
||||||
return /^[a-z]{2,3}(?:-[A-Z]{2})?$/.test(value || '');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getAcceptLanguage(marketplace) {
|
|
||||||
const configured =
|
|
||||||
marketplace.config?.acceptLanguage ||
|
|
||||||
marketplace.config?.locale ||
|
|
||||||
MARKETPLACE_LANGUAGE_MAP[marketplace.config?.marketplaceId];
|
|
||||||
|
|
||||||
if (typeof configured === 'string') {
|
|
||||||
const normalized = configured.replace('_', '-').trim();
|
|
||||||
if (isValidLanguageTag(normalized)) {
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'en-GB';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getTokenExpiryDate(expiresInSeconds) {
|
|
||||||
return new Date(Date.now() + Math.max(Number(expiresInSeconds || 0) - 60, 0) * 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isAccessTokenExpired(marketplace) {
|
|
||||||
const { accessToken, accessTokenExpiresAt } = marketplace.config || {};
|
|
||||||
if (!accessToken || !accessTokenExpiresAt) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Date(accessTokenExpiresAt).getTime() <= Date.now();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getRequiredAuthConfig(marketplace) {
|
|
||||||
const { clientId, clientSecret } = marketplace.config || {};
|
|
||||||
|
|
||||||
if (!clientId || !clientSecret) {
|
|
||||||
throw new Error('eBay marketplace is missing required config (clientId, clientSecret)');
|
|
||||||
}
|
|
||||||
|
|
||||||
return { clientId, clientSecret };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getBasicAuthHeader(marketplace) {
|
|
||||||
const { clientId, clientSecret } = getRequiredAuthConfig(marketplace);
|
|
||||||
return `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function makeRequest({
|
|
||||||
marketplace,
|
|
||||||
method = 'GET',
|
|
||||||
path,
|
|
||||||
params = {},
|
|
||||||
body = null,
|
|
||||||
acceptableStatuses = [],
|
|
||||||
} = {}) {
|
|
||||||
const { accessToken } = marketplace.config || {};
|
|
||||||
if (!accessToken) {
|
|
||||||
throw new Error(
|
|
||||||
'eBay marketplace is not authenticated. Complete marketplace authorization first.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const queryString = Object.entries(params)
|
|
||||||
.filter(([, value]) => value !== undefined && value !== null && value !== '')
|
|
||||||
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
|
|
||||||
.join('&');
|
|
||||||
|
|
||||||
const url = queryString
|
|
||||||
? `${getApiBaseUrl(marketplace)}${path}?${queryString}`
|
|
||||||
: `${getApiBaseUrl(marketplace)}${path}`;
|
|
||||||
const headers = {
|
|
||||||
Authorization: `Bearer ${accessToken}`,
|
|
||||||
Accept: 'application/json',
|
|
||||||
'Accept-Language': getAcceptLanguage(marketplace),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (marketplace.config.marketplaceId) {
|
|
||||||
headers['X-EBAY-C-MARKETPLACE-ID'] = marketplace.config.marketplaceId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchOptions = {
|
|
||||||
method,
|
|
||||||
headers,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (body && method !== 'GET') {
|
|
||||||
fetchOptions.headers['Content-Type'] = 'application/json';
|
|
||||||
fetchOptions.headers['Content-Language'] = getAcceptLanguage(marketplace);
|
|
||||||
fetchOptions.body = JSON.stringify(body);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug(`eBay API ${method} ${path}`);
|
|
||||||
|
|
||||||
const response = await fetch(url, fetchOptions);
|
|
||||||
|
|
||||||
if (response.status === 204) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const responseText = await response.text();
|
|
||||||
let data = null;
|
|
||||||
if (responseText) {
|
|
||||||
try {
|
|
||||||
data = JSON.parse(responseText);
|
|
||||||
} catch {
|
|
||||||
data = responseText;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok && acceptableStatuses.includes(response.status)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const message =
|
|
||||||
(typeof data === 'object' && (data?.errors?.[0]?.message || data?.error_description)) ||
|
|
||||||
(typeof data === 'string' && data) ||
|
|
||||||
response.statusText;
|
|
||||||
logger.error(`eBay API error: ${message}`, { status: response.status, path });
|
|
||||||
throw new Error(`eBay API error (${response.status}): ${message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export { logger };
|
|
||||||
@ -1,662 +0,0 @@
|
|||||||
import crypto from 'crypto';
|
|
||||||
import config from '../../config.js';
|
|
||||||
import log4js from 'log4js';
|
|
||||||
|
|
||||||
const logger = log4js.getLogger('TikTok Shop');
|
|
||||||
logger.level = config.server.logLevel;
|
|
||||||
|
|
||||||
const BASE_URL = 'https://open-api.tiktokglobalshop.com';
|
|
||||||
const AUTH_BASE_URL = 'https://auth.tiktok-shops.com';
|
|
||||||
const API_VERSION = '202309';
|
|
||||||
const AUTHORIZED_SHOPS_PATH = `/authorization/${API_VERSION}/shops`;
|
|
||||||
|
|
||||||
function getTokenExpiryDate(unixTimestampSeconds) {
|
|
||||||
return new Date(Number(unixTimestampSeconds || 0) * 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isAccessTokenExpired(marketplace) {
|
|
||||||
const { accessToken, accessTokenExpiresAt } = marketplace.config || {};
|
|
||||||
if (!accessToken || !accessTokenExpiresAt) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Date(accessTokenExpiresAt).getTime() <= Date.now();
|
|
||||||
}
|
|
||||||
|
|
||||||
function shouldIncludeShopCipher(path, method = 'GET') {
|
|
||||||
if (/^\/product\/(\d{6})\/(compliance|global_products|files\/upload|images\/upload)/.test(path)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (method === 'POST' && /^\/product\/(\d{6})\/brands/.test(path)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (/^\/(authorization|seller)\/(\d{6})\//.test(path)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateSignature({ path, method, params, body, appSecret }) {
|
|
||||||
const paramsToBeSigned = { ...params };
|
|
||||||
delete paramsToBeSigned.sign;
|
|
||||||
delete paramsToBeSigned.access_token;
|
|
||||||
delete paramsToBeSigned['x-tts-access-token'];
|
|
||||||
|
|
||||||
const sortedKeys = Object.keys(paramsToBeSigned).sort();
|
|
||||||
let payload = path;
|
|
||||||
|
|
||||||
for (const key of sortedKeys) {
|
|
||||||
const value = paramsToBeSigned[key];
|
|
||||||
if (!Array.isArray(value)) {
|
|
||||||
payload += `${key}${value}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (method !== 'GET' && body && typeof body === 'object') {
|
|
||||||
payload += JSON.stringify(body);
|
|
||||||
}
|
|
||||||
|
|
||||||
payload = `${appSecret}${payload}${appSecret}`;
|
|
||||||
return crypto.createHmac('sha256', appSecret).update(payload).digest('hex');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function requestAuth(path, params) {
|
|
||||||
const url = new URL(path, AUTH_BASE_URL);
|
|
||||||
for (const [key, value] of Object.entries(params)) {
|
|
||||||
if (value !== undefined && value !== null && value !== '') {
|
|
||||||
url.searchParams.set(key, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(url.toString(), { method: 'GET' });
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (!response.ok || data.code !== 0) {
|
|
||||||
const message = data.message || response.statusText;
|
|
||||||
logger.error(`TikTok Shop auth error: ${message}`, { path, code: data.code });
|
|
||||||
throw new Error(`TikTok Shop auth error: ${message} (code: ${data.code ?? response.status})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return data.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function makeRequest({ marketplace, method = 'GET', path, params = {}, body = null }) {
|
|
||||||
const { appKey, appSecret, accessToken, shopCipher } = marketplace.config || {};
|
|
||||||
|
|
||||||
if (!appKey || !appSecret || !accessToken) {
|
|
||||||
throw new Error(
|
|
||||||
'TikTok Shop marketplace is missing required config (appKey, appSecret, accessToken)'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const queryParams = {
|
|
||||||
...params,
|
|
||||||
app_key: appKey,
|
|
||||||
timestamp: Math.floor(Date.now() / 1000),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (shopCipher && shouldIncludeShopCipher(path, method)) {
|
|
||||||
queryParams.shop_cipher = shopCipher;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sign = generateSignature({
|
|
||||||
path,
|
|
||||||
method,
|
|
||||||
params: queryParams,
|
|
||||||
body,
|
|
||||||
appSecret,
|
|
||||||
});
|
|
||||||
|
|
||||||
queryParams.sign = sign;
|
|
||||||
|
|
||||||
const queryString = Object.entries(queryParams)
|
|
||||||
.filter(([, value]) => value !== undefined && value !== null && value !== '')
|
|
||||||
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
|
|
||||||
.join('&');
|
|
||||||
|
|
||||||
const url = `${BASE_URL}${path}?${queryString}`;
|
|
||||||
|
|
||||||
const fetchOptions = {
|
|
||||||
method,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'x-tts-access-token': accessToken,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
if (body && method !== 'GET') {
|
|
||||||
fetchOptions.body = JSON.stringify(body);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug(`TikTok Shop API ${method} ${path}`);
|
|
||||||
|
|
||||||
const response = await fetch(url, fetchOptions);
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.code !== 0) {
|
|
||||||
logger.error(`TikTok Shop API error: ${data.message}`, { code: data.code, path });
|
|
||||||
throw new Error(`TikTok Shop API error: ${data.message} (code: ${data.code})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return data.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchAuthorizedShops(marketplace, accessToken) {
|
|
||||||
const authMarketplace = {
|
|
||||||
...marketplace,
|
|
||||||
config: {
|
|
||||||
...(marketplace.config || {}),
|
|
||||||
accessToken,
|
|
||||||
shopCipher: undefined,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const data = await makeRequest({
|
|
||||||
marketplace: authMarketplace,
|
|
||||||
method: 'GET',
|
|
||||||
path: AUTHORIZED_SHOPS_PATH,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (Array.isArray(data?.shops)) {
|
|
||||||
return data.shops;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(data?.shop_list)) {
|
|
||||||
return data.shop_list;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(data)) {
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickAuthorizedShop(marketplace, shops) {
|
|
||||||
if (!shops.length) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingShopCipher = marketplace.config?.shopCipher;
|
|
||||||
const existingShopId = marketplace.config?.shopId;
|
|
||||||
const existingShopCode = marketplace.config?.shopCode;
|
|
||||||
|
|
||||||
return (
|
|
||||||
shops.find((shop) => shop.cipher === existingShopCipher) ||
|
|
||||||
shops.find((shop) => shop.id === existingShopId) ||
|
|
||||||
shops.find((shop) => shop.code === existingShopCode) ||
|
|
||||||
shops[0]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRequiredAuthConfig(marketplace) {
|
|
||||||
const { appKey, appSecret } = marketplace.config || {};
|
|
||||||
if (!appKey || !appSecret) {
|
|
||||||
throw new Error('TikTok Shop marketplace is missing required config (appKey, appSecret)');
|
|
||||||
}
|
|
||||||
|
|
||||||
return { appKey, appSecret };
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapAuthConfigUpdates(marketplace, tokenData, shop) {
|
|
||||||
return {
|
|
||||||
accessToken: tokenData.access_token,
|
|
||||||
accessTokenExpiresAt: getTokenExpiryDate(tokenData.access_token_expire_in).toISOString(),
|
|
||||||
refreshToken: tokenData.refresh_token || marketplace.config.refreshToken,
|
|
||||||
refreshTokenExpiresAt: tokenData.refresh_token_expire_in
|
|
||||||
? getTokenExpiryDate(tokenData.refresh_token_expire_in).toISOString()
|
|
||||||
: marketplace.config.refreshTokenExpiresAt,
|
|
||||||
openId: tokenData.open_id || marketplace.config.openId,
|
|
||||||
grantedScopes: tokenData.granted_scopes || marketplace.config.grantedScopes || [],
|
|
||||||
sellerName: tokenData.seller_name || shop?.name || marketplace.config.sellerName,
|
|
||||||
sellerBaseRegion:
|
|
||||||
tokenData.seller_base_region || shop?.region || marketplace.config.sellerBaseRegion,
|
|
||||||
shopCipher: shop?.cipher || marketplace.config.shopCipher,
|
|
||||||
shopId: shop?.id || marketplace.config.shopId,
|
|
||||||
shopCode: shop?.code || marketplace.config.shopCode,
|
|
||||||
shopName: shop?.name || marketplace.config.shopName,
|
|
||||||
shopRegion: shop?.region || marketplace.config.shopRegion,
|
|
||||||
lastTokenRefreshAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createAuthorizationUrl(marketplace, { state } = {}) {
|
|
||||||
const { appKey } = getRequiredAuthConfig(marketplace);
|
|
||||||
|
|
||||||
const url = new URL('/oauth/authorize', AUTH_BASE_URL);
|
|
||||||
url.searchParams.set('app_key', appKey);
|
|
||||||
|
|
||||||
if (state) {
|
|
||||||
url.searchParams.set('state', state);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (marketplace.config.redirectUri) {
|
|
||||||
url.searchParams.set('redirect_uri', marketplace.config.redirectUri);
|
|
||||||
}
|
|
||||||
|
|
||||||
return url.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function exchangeAuthorizationCode(marketplace, { code }) {
|
|
||||||
const { appKey, appSecret } = getRequiredAuthConfig(marketplace);
|
|
||||||
if (!code) {
|
|
||||||
throw new Error('Missing TikTok Shop authorization code');
|
|
||||||
}
|
|
||||||
|
|
||||||
const tokenData = await requestAuth('/api/v2/token/get', {
|
|
||||||
app_key: appKey,
|
|
||||||
app_secret: appSecret,
|
|
||||||
auth_code: code,
|
|
||||||
grant_type: 'authorized_code',
|
|
||||||
});
|
|
||||||
|
|
||||||
const shops = await fetchAuthorizedShops(marketplace, tokenData.access_token);
|
|
||||||
const selectedShop = pickAuthorizedShop(marketplace, shops);
|
|
||||||
|
|
||||||
return {
|
|
||||||
configUpdates: mapAuthConfigUpdates(marketplace, tokenData, selectedShop),
|
|
||||||
marketplaceUpdates: {
|
|
||||||
connected: true,
|
|
||||||
connectedAt: new Date(),
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
shopCount: shops.length,
|
|
||||||
selectedShop,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function refreshAuth(marketplace) {
|
|
||||||
const { appKey, appSecret } = getRequiredAuthConfig(marketplace);
|
|
||||||
const { refreshToken } = marketplace.config || {};
|
|
||||||
|
|
||||||
if (!refreshToken) {
|
|
||||||
throw new Error('TikTok Shop marketplace is missing required config (refreshToken)');
|
|
||||||
}
|
|
||||||
|
|
||||||
const tokenData = await requestAuth('/api/v2/token/refresh', {
|
|
||||||
app_key: appKey,
|
|
||||||
app_secret: appSecret,
|
|
||||||
refresh_token: refreshToken,
|
|
||||||
grant_type: 'refresh_token',
|
|
||||||
});
|
|
||||||
|
|
||||||
let selectedShop = null;
|
|
||||||
if (!marketplace.config?.shopCipher) {
|
|
||||||
const shops = await fetchAuthorizedShops(marketplace, tokenData.access_token);
|
|
||||||
selectedShop = pickAuthorizedShop(marketplace, shops);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
configUpdates: mapAuthConfigUpdates(marketplace, tokenData, selectedShop),
|
|
||||||
data: {
|
|
||||||
selectedShop,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function ensureAuthenticatedMarketplace(marketplace) {
|
|
||||||
if (!isAccessTokenExpired(marketplace)) {
|
|
||||||
return { marketplace };
|
|
||||||
}
|
|
||||||
|
|
||||||
const authResult = await refreshAuth(marketplace);
|
|
||||||
return {
|
|
||||||
marketplace: {
|
|
||||||
...marketplace,
|
|
||||||
config: {
|
|
||||||
...(marketplace.config || {}),
|
|
||||||
...authResult.configUpdates,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
configUpdates: authResult.configUpdates,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchAllProducts(marketplace) {
|
|
||||||
const products = [];
|
|
||||||
let pageToken = '';
|
|
||||||
|
|
||||||
do {
|
|
||||||
const body = {
|
|
||||||
page_size: 100,
|
|
||||||
};
|
|
||||||
if (pageToken) {
|
|
||||||
body.page_token = pageToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await makeRequest({
|
|
||||||
marketplace,
|
|
||||||
method: 'POST',
|
|
||||||
path: `/product/${API_VERSION}/products/search`,
|
|
||||||
body,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (data.products?.length) {
|
|
||||||
products.push(...data.products);
|
|
||||||
}
|
|
||||||
|
|
||||||
pageToken = data.next_page_token || '';
|
|
||||||
} while (pageToken);
|
|
||||||
|
|
||||||
return products;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchProductDetail(marketplace, productId) {
|
|
||||||
return makeRequest({
|
|
||||||
marketplace,
|
|
||||||
method: 'GET',
|
|
||||||
path: `/product/${API_VERSION}/products/${productId}`,
|
|
||||||
params: { return_under_review_version: false },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchAllOrders(marketplace, { startTime, endTime } = {}) {
|
|
||||||
const orders = [];
|
|
||||||
let pageToken = '';
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
|
||||||
|
|
||||||
do {
|
|
||||||
const body = {
|
|
||||||
page_size: 50,
|
|
||||||
sort_field: 'CREATE_TIME',
|
|
||||||
sort_order: 'DESC',
|
|
||||||
};
|
|
||||||
|
|
||||||
if (startTime || endTime) {
|
|
||||||
body.create_time_ge = startTime || now - 86400 * 30;
|
|
||||||
body.create_time_lt = endTime || now;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pageToken) {
|
|
||||||
body.page_token = pageToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await makeRequest({
|
|
||||||
marketplace,
|
|
||||||
method: 'POST',
|
|
||||||
path: `/order/${API_VERSION}/orders/search`,
|
|
||||||
body,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (data.orders?.length) {
|
|
||||||
orders.push(...data.orders);
|
|
||||||
}
|
|
||||||
|
|
||||||
pageToken = data.next_page_token || '';
|
|
||||||
} while (pageToken);
|
|
||||||
|
|
||||||
return orders;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchOrderDetail(marketplace, orderId) {
|
|
||||||
return makeRequest({
|
|
||||||
marketplace,
|
|
||||||
method: 'GET',
|
|
||||||
path: `/order/${API_VERSION}/orders/${orderId}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapListingToProduct(listing) {
|
|
||||||
const product = {
|
|
||||||
title: listing.title || '',
|
|
||||||
is_cod_allowed: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (listing.description) {
|
|
||||||
product.description = listing.description;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (listing.categoryId) {
|
|
||||||
product.category_id = listing.categoryId;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (listing.imageUrls?.length) {
|
|
||||||
product.main_images = listing.imageUrls.map((url) => ({ uri: url }));
|
|
||||||
}
|
|
||||||
|
|
||||||
const sku = {
|
|
||||||
inventory: [{ quantity: listing.inventory ?? 0 }],
|
|
||||||
};
|
|
||||||
|
|
||||||
if (listing.price != null) {
|
|
||||||
sku.price = {
|
|
||||||
amount: String(listing.price),
|
|
||||||
currency: listing.currency || 'GBP',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (listing.externalId) {
|
|
||||||
sku.seller_sku = listing.externalId;
|
|
||||||
}
|
|
||||||
|
|
||||||
product.skus = [sku];
|
|
||||||
|
|
||||||
return product;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createItem(marketplace, listing) {
|
|
||||||
logger.info(`Creating TikTok Shop product: ${listing.title}`);
|
|
||||||
|
|
||||||
const productBody = mapListingToProduct(listing);
|
|
||||||
const result = await makeRequest({
|
|
||||||
marketplace,
|
|
||||||
method: 'POST',
|
|
||||||
path: `/product/${API_VERSION}/products`,
|
|
||||||
body: productBody,
|
|
||||||
});
|
|
||||||
|
|
||||||
const productId = result?.product_id || result?.id;
|
|
||||||
|
|
||||||
return {
|
|
||||||
externalId: productId,
|
|
||||||
url: '',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateItem(marketplace, listing) {
|
|
||||||
const productId = listing.externalId;
|
|
||||||
if (!productId) {
|
|
||||||
throw new Error('Listing must have an externalId to update on TikTok Shop');
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`Updating TikTok Shop product: ${productId}`);
|
|
||||||
|
|
||||||
const productBody = mapListingToProduct(listing);
|
|
||||||
await makeRequest({
|
|
||||||
marketplace,
|
|
||||||
method: 'PUT',
|
|
||||||
path: `/product/${API_VERSION}/products/${productId}`,
|
|
||||||
body: productBody,
|
|
||||||
});
|
|
||||||
|
|
||||||
return { externalId: productId };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function syncItems(marketplace) {
|
|
||||||
logger.info(`Syncing products from TikTok Shop marketplace: ${marketplace.name}`);
|
|
||||||
|
|
||||||
const products = await fetchAllProducts(marketplace);
|
|
||||||
const detailed = [];
|
|
||||||
|
|
||||||
for (const product of products) {
|
|
||||||
try {
|
|
||||||
const detail = await fetchProductDetail(marketplace, product.id);
|
|
||||||
detailed.push(detail);
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn(`Failed to fetch detail for product ${product.id}: ${err.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`Fetched ${detailed.length} product(s) from TikTok Shop`);
|
|
||||||
return detailed;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function syncOrders(marketplace, { startTime, endTime } = {}) {
|
|
||||||
logger.info(`Syncing orders from TikTok Shop marketplace: ${marketplace.name}`);
|
|
||||||
|
|
||||||
const orders = await fetchAllOrders(marketplace, { startTime, endTime });
|
|
||||||
const detailed = [];
|
|
||||||
|
|
||||||
for (const order of orders) {
|
|
||||||
try {
|
|
||||||
const detail = await fetchOrderDetail(marketplace, order.id);
|
|
||||||
detailed.push(detail);
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn(`Failed to fetch detail for order ${order.id}: ${err.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`Fetched ${detailed.length} order(s) from TikTok Shop`);
|
|
||||||
return detailed;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ORDER_STATUS_MAP = {
|
|
||||||
UNPAID: 'draft',
|
|
||||||
ON_HOLD: 'draft',
|
|
||||||
AWAITING_SHIPMENT: 'confirmed',
|
|
||||||
PARTIALLY_SHIPPING: 'partiallyShipped',
|
|
||||||
AWAITING_COLLECTION: 'shipped',
|
|
||||||
IN_TRANSIT: 'shipped',
|
|
||||||
DELIVERED: 'delivered',
|
|
||||||
COMPLETED: 'completed',
|
|
||||||
CANCELLED: 'cancelled',
|
|
||||||
};
|
|
||||||
|
|
||||||
export function mapOrderStatus(tiktokStatus) {
|
|
||||||
return ORDER_STATUS_MAP[tiktokStatus] || 'draft';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mapOrderToSalesOrder(tiktokOrder) {
|
|
||||||
const paymentInfo = tiktokOrder.payment || {};
|
|
||||||
const totalAmount = parseFloat(paymentInfo.product_total_price || 0);
|
|
||||||
const shippingAmount = parseFloat(paymentInfo.shipping_fee || 0);
|
|
||||||
const totalTax = parseFloat(paymentInfo.tax || 0);
|
|
||||||
|
|
||||||
return {
|
|
||||||
externalId: tiktokOrder.id,
|
|
||||||
state: { type: mapOrderStatus(tiktokOrder.status) },
|
|
||||||
totalAmount,
|
|
||||||
totalAmountWithTax: totalAmount + totalTax,
|
|
||||||
shippingAmount,
|
|
||||||
shippingAmountWithTax: shippingAmount,
|
|
||||||
grandTotalAmount: parseFloat(paymentInfo.total_amount || 0),
|
|
||||||
totalTaxAmount: totalTax,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const PRODUCT_STATUS_MAP = {
|
|
||||||
DRAFT: 'draft',
|
|
||||||
PENDING: 'draft',
|
|
||||||
LIVE: 'active',
|
|
||||||
SELLER_DEACTIVATED: 'inactive',
|
|
||||||
PLATFORM_DEACTIVATED: 'suspended',
|
|
||||||
FROZEN: 'suspended',
|
|
||||||
DELETED: 'deleted',
|
|
||||||
};
|
|
||||||
|
|
||||||
export function mapProductToListing(tiktokProduct) {
|
|
||||||
const firstSku = tiktokProduct.skus?.[0];
|
|
||||||
const price = firstSku?.price?.sale_price
|
|
||||||
? parseFloat(firstSku.price.sale_price)
|
|
||||||
: firstSku?.price?.original_price
|
|
||||||
? parseFloat(firstSku.price.original_price)
|
|
||||||
: undefined;
|
|
||||||
const currency = firstSku?.price?.currency || undefined;
|
|
||||||
const totalInventory = (tiktokProduct.skus || []).reduce(
|
|
||||||
(sum, sku) => sum + (sku.inventory?.[0]?.quantity || 0),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
|
|
||||||
const varients = (tiktokProduct.skus || []).map((sku) => {
|
|
||||||
const skuPrice = sku.price?.sale_price
|
|
||||||
? parseFloat(sku.price.sale_price)
|
|
||||||
: sku.price?.original_price
|
|
||||||
? parseFloat(sku.price.original_price)
|
|
||||||
: undefined;
|
|
||||||
return {
|
|
||||||
_reference: sku.id || sku.seller_sku || '',
|
|
||||||
price: skuPrice,
|
|
||||||
currency: sku.price?.currency || currency,
|
|
||||||
state: { type: PRODUCT_STATUS_MAP[tiktokProduct.status] || 'draft' },
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
_reference: tiktokProduct.id,
|
|
||||||
title: tiktokProduct.title || '',
|
|
||||||
state: { type: PRODUCT_STATUS_MAP[tiktokProduct.status] || 'draft' },
|
|
||||||
price,
|
|
||||||
currency,
|
|
||||||
inventory: totalInventory,
|
|
||||||
url: tiktokProduct.url || '',
|
|
||||||
varients,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mapBuyerToClient(tiktokOrder) {
|
|
||||||
const buyer = tiktokOrder.buyer_info || {};
|
|
||||||
const address = tiktokOrder.recipient_address || {};
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: buyer.name || address.name || 'Unknown',
|
|
||||||
email: buyer.email || '',
|
|
||||||
phone: buyer.phone_number || address.phone_number || '',
|
|
||||||
address: {
|
|
||||||
addressLine1: address.address_line1 || '',
|
|
||||||
addressLine2: address.address_line2 || '',
|
|
||||||
city: address.city || '',
|
|
||||||
state: address.state || '',
|
|
||||||
postcode: address.zipcode || '',
|
|
||||||
country: address.region_code || '',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function handleWebhook(marketplace, event) {
|
|
||||||
const { type, data } = event;
|
|
||||||
|
|
||||||
logger.info(`TikTok Shop webhook received: ${type} for marketplace ${marketplace.name}`);
|
|
||||||
|
|
||||||
switch (type) {
|
|
||||||
case 'ORDER_STATUS_CHANGE':
|
|
||||||
return { action: 'orderUpdate', orderId: data.order_id, status: data.order_status };
|
|
||||||
|
|
||||||
case 'ORDER_CREATION':
|
|
||||||
return { action: 'orderCreate', orderId: data.order_id };
|
|
||||||
|
|
||||||
case 'PRODUCT_STATUS_CHANGE':
|
|
||||||
return { action: 'productUpdate', productId: data.product_id, status: data.product_status };
|
|
||||||
|
|
||||||
case 'CANCELLATION_REQUEST':
|
|
||||||
return { action: 'orderCancel', orderId: data.order_id };
|
|
||||||
|
|
||||||
case 'RETURN_REQUEST':
|
|
||||||
return { action: 'returnRequest', orderId: data.order_id, returnId: data.return_id };
|
|
||||||
|
|
||||||
default:
|
|
||||||
logger.debug(`Unhandled TikTok Shop webhook type: ${type}`);
|
|
||||||
return { action: 'unknown', type };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function canVerifyWebhookSignature(marketplace) {
|
|
||||||
return !!marketplace.config?.appSecret;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function verifyWebhookSignature(marketplace, body, signature) {
|
|
||||||
const appSecret = marketplace.config?.appSecret;
|
|
||||||
if (!appSecret) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const computed = crypto.createHmac('sha256', appSecret).update(body).digest('hex');
|
|
||||||
try {
|
|
||||||
return crypto.timingSafeEqual(Buffer.from(computed), Buffer.from(signature));
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,591 +0,0 @@
|
|||||||
import config from '../config.js';
|
|
||||||
import log4js from 'log4js';
|
|
||||||
import { clientModel } from '../database/schemas/sales/client.schema.js';
|
|
||||||
import { salesOrderModel } from '../database/schemas/sales/salesorder.schema.js';
|
|
||||||
import { listingModel } from '../database/schemas/sales/listing.schema.js';
|
|
||||||
import { listingVarientModel } from '../database/schemas/sales/listingvarient.schema.js';
|
|
||||||
import { marketplaceModel } from '../database/schemas/sales/marketplace.schema.js';
|
|
||||||
import { editObject, newObject } from '../database/database.js';
|
|
||||||
import * as tiktokShop from './marketplaces/tiktokShop.js';
|
|
||||||
import * as ebay from './marketplaces/ebay/index.js';
|
|
||||||
|
|
||||||
const logger = log4js.getLogger('Marketplace Worker');
|
|
||||||
logger.level = config.server.logLevel;
|
|
||||||
|
|
||||||
const providers = {
|
|
||||||
tiktokShop,
|
|
||||||
ebay,
|
|
||||||
};
|
|
||||||
|
|
||||||
function getProvider(marketplace) {
|
|
||||||
const provider = providers[marketplace.provider];
|
|
||||||
if (!provider) {
|
|
||||||
throw new Error(`No integration available for provider: ${marketplace.provider}`);
|
|
||||||
}
|
|
||||||
return provider;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hasIntegration(provider) {
|
|
||||||
return !!providers[provider];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function publishMarketplaceOfferForSku(marketplace, user, sku, listing) {
|
|
||||||
const authenticatedMarketplace = await ensureMarketplaceAuth(marketplace, user);
|
|
||||||
const provider = getProvider(authenticatedMarketplace);
|
|
||||||
if (typeof provider.publishOfferForSku !== 'function') {
|
|
||||||
throw new Error(
|
|
||||||
`Marketplace provider "${marketplace.provider}" does not support publishing offers`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return provider.publishOfferForSku(authenticatedMarketplace, sku, listing);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function withdrawMarketplaceOfferForSku(marketplace, user, sku) {
|
|
||||||
const authenticatedMarketplace = await ensureMarketplaceAuth(marketplace, user);
|
|
||||||
const provider = getProvider(authenticatedMarketplace);
|
|
||||||
if (typeof provider.withdrawOfferForSku !== 'function') {
|
|
||||||
throw new Error(
|
|
||||||
`Marketplace provider "${marketplace.provider}" does not support withdrawing offers`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return provider.withdrawOfferForSku(authenticatedMarketplace, sku);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function persistMarketplaceUpdate(marketplace, configUpdates, marketplaceUpdates, user) {
|
|
||||||
const updateData = { updatedAt: new Date() };
|
|
||||||
|
|
||||||
if (configUpdates && Object.keys(configUpdates).length > 0) {
|
|
||||||
updateData.config = {
|
|
||||||
...(marketplace.config || {}),
|
|
||||||
...configUpdates,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (marketplaceUpdates && Object.keys(marketplaceUpdates).length > 0) {
|
|
||||||
Object.assign(updateData, marketplaceUpdates);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.keys(updateData).length <= 1) {
|
|
||||||
return marketplace;
|
|
||||||
}
|
|
||||||
|
|
||||||
return editObject({
|
|
||||||
model: marketplaceModel,
|
|
||||||
id: marketplace._id,
|
|
||||||
updateData,
|
|
||||||
user,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ensureMarketplaceAuth(marketplace, user) {
|
|
||||||
const provider = getProvider(marketplace);
|
|
||||||
|
|
||||||
if (!provider.ensureAuthenticatedMarketplace) {
|
|
||||||
return marketplace;
|
|
||||||
}
|
|
||||||
|
|
||||||
const authResult = await provider.ensureAuthenticatedMarketplace(marketplace);
|
|
||||||
if (!authResult?.configUpdates) {
|
|
||||||
return authResult?.marketplace || marketplace;
|
|
||||||
}
|
|
||||||
|
|
||||||
return persistMarketplaceUpdate(
|
|
||||||
marketplace,
|
|
||||||
authResult.configUpdates,
|
|
||||||
authResult.marketplaceUpdates,
|
|
||||||
user
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function canAuthorize(marketplace) {
|
|
||||||
const provider = getProvider(marketplace);
|
|
||||||
return (
|
|
||||||
typeof provider.createAuthorizationUrl === 'function' &&
|
|
||||||
typeof provider.exchangeAuthorizationCode === 'function'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getAuthorizationUrl(marketplace, { state } = {}) {
|
|
||||||
const provider = getProvider(marketplace);
|
|
||||||
if (!provider.createAuthorizationUrl) {
|
|
||||||
throw new Error(`Provider ${marketplace.provider} does not support marketplace authorization`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return provider.createAuthorizationUrl(marketplace, { state });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function exchangeAuthorizationCode(marketplace, user, { code, state } = {}) {
|
|
||||||
const provider = getProvider(marketplace);
|
|
||||||
if (!provider.exchangeAuthorizationCode) {
|
|
||||||
throw new Error(`Provider ${marketplace.provider} does not support marketplace authorization`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const authResult = await provider.exchangeAuthorizationCode(marketplace, { code, state });
|
|
||||||
const updatedMarketplace = await persistMarketplaceUpdate(
|
|
||||||
marketplace,
|
|
||||||
authResult?.configUpdates || {},
|
|
||||||
authResult?.marketplaceUpdates,
|
|
||||||
user
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
marketplace: updatedMarketplace,
|
|
||||||
...(authResult?.data ? { data: authResult.data } : {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function refreshMarketplaceAuth(marketplace, user) {
|
|
||||||
const provider = getProvider(marketplace);
|
|
||||||
if (!provider.refreshAuth) {
|
|
||||||
throw new Error(`Provider ${marketplace.provider} does not support token refresh`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const authResult = await provider.refreshAuth(marketplace);
|
|
||||||
const updatedMarketplace = await persistMarketplaceUpdate(
|
|
||||||
marketplace,
|
|
||||||
authResult?.configUpdates || {},
|
|
||||||
authResult?.marketplaceUpdates,
|
|
||||||
user
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
marketplace: updatedMarketplace,
|
|
||||||
...(authResult?.data ? { data: authResult.data } : {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function canVerifyWebhookSignature(marketplace) {
|
|
||||||
const provider = getProvider(marketplace);
|
|
||||||
if (typeof provider.verifyWebhookSignature !== 'function') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof provider.canVerifyWebhookSignature === 'function') {
|
|
||||||
return provider.canVerifyWebhookSignature(marketplace);
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function verifyWebhookSignature(marketplace, rawBody, signature) {
|
|
||||||
const provider = getProvider(marketplace);
|
|
||||||
if (!provider.verifyWebhookSignature) {
|
|
||||||
logger.warn(`Provider ${marketplace.provider} does not support webhook signature verification`);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return provider.verifyWebhookSignature(marketplace, rawBody, signature);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function handleWebhook(marketplace, event) {
|
|
||||||
const provider = getProvider(marketplace);
|
|
||||||
return provider.handleWebhook(marketplace, event);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setListingState(listingId, stateType, user, message) {
|
|
||||||
const state = { type: stateType };
|
|
||||||
if (message) state.message = message;
|
|
||||||
return editObject({
|
|
||||||
model: listingModel,
|
|
||||||
id: listingId,
|
|
||||||
updateData: { state },
|
|
||||||
user,
|
|
||||||
recalculate: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setListingVarientState(varientId, stateType, user, message) {
|
|
||||||
const state = { type: stateType };
|
|
||||||
if (message) state.message = message;
|
|
||||||
return editObject({
|
|
||||||
model: listingVarientModel,
|
|
||||||
id: varientId,
|
|
||||||
updateData: { state },
|
|
||||||
user,
|
|
||||||
recalculate: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setMarketplaceState(marketplaceId, stateType, user, message) {
|
|
||||||
const state = { type: stateType };
|
|
||||||
if (message) state.message = message;
|
|
||||||
return editObject({
|
|
||||||
model: marketplaceModel,
|
|
||||||
id: marketplaceId,
|
|
||||||
updateData: { state },
|
|
||||||
user,
|
|
||||||
recalculate: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function recalculateMarketplaceState(marketplace, user) {
|
|
||||||
await marketplaceModel.recalculate(marketplace, user);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchFullListing(listingId) {
|
|
||||||
return listingModel
|
|
||||||
.findById(listingId)
|
|
||||||
.populate(['product', 'vendor', 'stockLocation', 'marketplace', 'courierServices'])
|
|
||||||
.lean();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchListingVarients(listingId) {
|
|
||||||
return listingVarientModel.find({ listing: listingId }).lean();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createListing(marketplace, user, listingData) {
|
|
||||||
const provider = getProvider(marketplace);
|
|
||||||
if (!provider.createItem) {
|
|
||||||
logger.debug(`Provider ${marketplace.provider} does not support createItem — skipping`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (listingData._id) {
|
|
||||||
setListingState(listingData._id, 'syncing', user).catch((err) =>
|
|
||||||
logger.warn(`Failed to set listing syncing state: ${err.message}`)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const work = async () => {
|
|
||||||
try {
|
|
||||||
const authenticatedMarketplace = await ensureMarketplaceAuth(marketplace, user);
|
|
||||||
const fullListing = listingData._id ? await fetchFullListing(listingData._id) : listingData;
|
|
||||||
if (!fullListing) throw new Error('Listing not found');
|
|
||||||
|
|
||||||
const varients = listingData._id ? await fetchListingVarients(listingData._id) : [];
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
`Creating listing on marketplace "${marketplace.name}" (${marketplace.provider})`
|
|
||||||
);
|
|
||||||
const result = await provider.createItem(authenticatedMarketplace, fullListing, varients);
|
|
||||||
|
|
||||||
if (listingData._id) {
|
|
||||||
const updateData = { lastSyncedAt: new Date(), state: { type: 'active' } };
|
|
||||||
if (result?.url) updateData.url = result.url;
|
|
||||||
await editObject({ model: listingModel, id: listingData._id, updateData, user });
|
|
||||||
|
|
||||||
for (const varient of varients) {
|
|
||||||
await editObject({
|
|
||||||
model: listingVarientModel,
|
|
||||||
id: varient._id,
|
|
||||||
updateData: { lastSyncedAt: new Date(), state: { type: 'active' } },
|
|
||||||
user,
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`Background createListing complete for marketplace "${marketplace.name}"`);
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(
|
|
||||||
`Background createListing failed for marketplace "${marketplace.name}": ${err.message}`
|
|
||||||
);
|
|
||||||
if (listingData._id) {
|
|
||||||
await setListingState(listingData._id, 'draft', user, err.message).catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
work();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateListing(marketplace, user, listingData) {
|
|
||||||
const provider = getProvider(marketplace);
|
|
||||||
if (!provider.updateItem) {
|
|
||||||
logger.debug(`Provider ${marketplace.provider} does not support updateItem — skipping`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (listingData._id) {
|
|
||||||
setListingState(listingData._id, 'syncing', user).catch((err) =>
|
|
||||||
logger.warn(`Failed to set listing syncing state: ${err.message}`)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const work = async () => {
|
|
||||||
try {
|
|
||||||
const authenticatedMarketplace = await ensureMarketplaceAuth(marketplace, user);
|
|
||||||
const fullListing = listingData._id ? await fetchFullListing(listingData._id) : listingData;
|
|
||||||
if (!fullListing) throw new Error('Listing not found');
|
|
||||||
|
|
||||||
const varients = listingData._id ? await fetchListingVarients(listingData._id) : [];
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
`Updating listing on marketplace "${marketplace.name}" (${marketplace.provider})`
|
|
||||||
);
|
|
||||||
await provider.updateItem(authenticatedMarketplace, fullListing, varients);
|
|
||||||
|
|
||||||
if (listingData._id) {
|
|
||||||
await editObject({
|
|
||||||
model: listingModel,
|
|
||||||
id: listingData._id,
|
|
||||||
updateData: { state: { type: 'active' }, lastSyncedAt: new Date() },
|
|
||||||
user,
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const varient of varients) {
|
|
||||||
await editObject({
|
|
||||||
model: listingVarientModel,
|
|
||||||
id: varient._id,
|
|
||||||
updateData: { lastSyncedAt: new Date(), state: { type: 'active' } },
|
|
||||||
user,
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`Background updateListing complete for marketplace "${marketplace.name}"`);
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(
|
|
||||||
`Background updateListing failed for marketplace "${marketplace.name}": ${err.message}`
|
|
||||||
);
|
|
||||||
if (listingData._id) {
|
|
||||||
await setListingState(listingData._id, 'active', user, err.message).catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
work();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deleteListing(marketplace, user, listingData) {
|
|
||||||
const provider = getProvider(marketplace);
|
|
||||||
if (!provider.deleteItem) {
|
|
||||||
logger.debug(`Provider ${marketplace.provider} does not support deleteItem — skipping`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const work = async () => {
|
|
||||||
try {
|
|
||||||
const authenticatedMarketplace = await ensureMarketplaceAuth(marketplace, user);
|
|
||||||
logger.info(
|
|
||||||
`Deleting listing from marketplace "${marketplace.name}" (${marketplace.provider})`
|
|
||||||
);
|
|
||||||
await provider.deleteItem(authenticatedMarketplace, listingData);
|
|
||||||
logger.info(`Background deleteListing complete for marketplace "${marketplace.name}"`);
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(
|
|
||||||
`Background deleteListing failed for marketplace "${marketplace.name}": ${err.message}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
work();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function syncItems(marketplace, user) {
|
|
||||||
setMarketplaceState(marketplace._id, 'syncing', user).catch((err) =>
|
|
||||||
logger.warn(`Failed to set marketplace syncing state: ${err.message}`)
|
|
||||||
);
|
|
||||||
|
|
||||||
const work = async () => {
|
|
||||||
try {
|
|
||||||
const provider = getProvider(marketplace);
|
|
||||||
const authenticatedMarketplace = await ensureMarketplaceAuth(marketplace, user);
|
|
||||||
logger.info(
|
|
||||||
`Starting item sync for marketplace "${marketplace.name}" (${marketplace.provider})`
|
|
||||||
);
|
|
||||||
|
|
||||||
const existingListings = await listingModel
|
|
||||||
.find({
|
|
||||||
marketplace: authenticatedMarketplace._id,
|
|
||||||
'state.type': { $ne: 'deleted' },
|
|
||||||
})
|
|
||||||
.lean();
|
|
||||||
for (const listing of existingListings) {
|
|
||||||
await setListingState(listing._id, 'syncing', user).catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingVarients = await listingVarientModel
|
|
||||||
.find({
|
|
||||||
listing: { $in: existingListings.map((l) => l._id) },
|
|
||||||
})
|
|
||||||
.lean();
|
|
||||||
for (const varient of existingVarients) {
|
|
||||||
await setListingVarientState(varient._id, 'syncing', user).catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
const results = [];
|
|
||||||
|
|
||||||
for (const listing of existingListings) {
|
|
||||||
try {
|
|
||||||
const listingVarients = existingVarients.filter(
|
|
||||||
(varient) => String(varient.listing) === String(listing._id)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!listingVarients.length) {
|
|
||||||
throw new Error('Listing has no varients to sync');
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await provider.updateItem(
|
|
||||||
authenticatedMarketplace,
|
|
||||||
listing,
|
|
||||||
listingVarients
|
|
||||||
);
|
|
||||||
|
|
||||||
const listingUpdateData = {
|
|
||||||
lastSyncedAt: new Date(),
|
|
||||||
state: listing.state || { type: 'draft' },
|
|
||||||
};
|
|
||||||
if (result?.url) {
|
|
||||||
listingUpdateData.url = result.url;
|
|
||||||
}
|
|
||||||
|
|
||||||
await editObject({
|
|
||||||
model: listingModel,
|
|
||||||
id: listing._id,
|
|
||||||
updateData: listingUpdateData,
|
|
||||||
user,
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const varient of listingVarients) {
|
|
||||||
await editObject({
|
|
||||||
model: listingVarientModel,
|
|
||||||
id: varient._id,
|
|
||||||
updateData: {
|
|
||||||
lastSyncedAt: new Date(),
|
|
||||||
state: varient.state || { type: 'draft' },
|
|
||||||
},
|
|
||||||
user,
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
results.push({ _reference: listing._reference, action: 'synced', id: listing._id });
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn(`Failed to sync listing ${listing._reference}: ${err.message}`);
|
|
||||||
await setListingState(
|
|
||||||
listing._id,
|
|
||||||
listing.state?.type || 'draft',
|
|
||||||
user,
|
|
||||||
err.message
|
|
||||||
).catch(() => {});
|
|
||||||
results.push({
|
|
||||||
_reference: listing._reference,
|
|
||||||
action: 'error',
|
|
||||||
error: err.message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
`Item sync complete for marketplace ${marketplace.name}: ${results.length} processed`
|
|
||||||
);
|
|
||||||
|
|
||||||
await recalculateMarketplaceState(marketplace, user);
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(
|
|
||||||
`Background syncItems failed for marketplace "${marketplace.name}": ${err.message}`
|
|
||||||
);
|
|
||||||
await recalculateMarketplaceState(marketplace, user);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
work();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function syncOrders(marketplace, user, { startTime, endTime } = {}) {
|
|
||||||
setMarketplaceState(marketplace._id, 'syncing', user).catch((err) =>
|
|
||||||
logger.warn(`Failed to set marketplace syncing state: ${err.message}`)
|
|
||||||
);
|
|
||||||
|
|
||||||
const work = async () => {
|
|
||||||
try {
|
|
||||||
const provider = getProvider(marketplace);
|
|
||||||
const authenticatedMarketplace = await ensureMarketplaceAuth(marketplace, user);
|
|
||||||
logger.info(
|
|
||||||
`Starting order sync for marketplace "${marketplace.name}" (${marketplace.provider})`
|
|
||||||
);
|
|
||||||
|
|
||||||
const externalOrders = await provider.syncOrders(authenticatedMarketplace, {
|
|
||||||
startTime,
|
|
||||||
endTime,
|
|
||||||
});
|
|
||||||
const results = [];
|
|
||||||
|
|
||||||
for (const externalOrder of externalOrders) {
|
|
||||||
try {
|
|
||||||
const mapped = provider.mapOrderToSalesOrder(externalOrder);
|
|
||||||
const clientData = provider.mapBuyerToClient(externalOrder);
|
|
||||||
|
|
||||||
let client = await clientModel.findOne({
|
|
||||||
name: clientData.name,
|
|
||||||
marketplace: authenticatedMarketplace._id,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!client) {
|
|
||||||
client = await newObject({
|
|
||||||
model: clientModel,
|
|
||||||
newData: {
|
|
||||||
...clientData,
|
|
||||||
marketplace: authenticatedMarketplace._id,
|
|
||||||
active: true,
|
|
||||||
},
|
|
||||||
user,
|
|
||||||
});
|
|
||||||
logger.debug(`Created client "${clientData.name}" from marketplace order`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingOrder = await salesOrderModel.findOne({
|
|
||||||
marketplace: authenticatedMarketplace._id,
|
|
||||||
_reference: mapped.externalId,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existingOrder) {
|
|
||||||
await editObject({
|
|
||||||
model: salesOrderModel,
|
|
||||||
id: existingOrder._id,
|
|
||||||
updateData: {
|
|
||||||
state: mapped.state,
|
|
||||||
totalAmount: mapped.totalAmount,
|
|
||||||
totalAmountWithTax: mapped.totalAmountWithTax,
|
|
||||||
shippingAmount: mapped.shippingAmount,
|
|
||||||
shippingAmountWithTax: mapped.shippingAmountWithTax,
|
|
||||||
grandTotalAmount: mapped.grandTotalAmount,
|
|
||||||
totalTaxAmount: mapped.totalTaxAmount,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
},
|
|
||||||
user,
|
|
||||||
});
|
|
||||||
results.push({
|
|
||||||
externalId: mapped.externalId,
|
|
||||||
action: 'updated',
|
|
||||||
id: existingOrder._id,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const salesOrder = await newObject({
|
|
||||||
model: salesOrderModel,
|
|
||||||
newData: {
|
|
||||||
_reference: mapped.externalId,
|
|
||||||
client: client._id,
|
|
||||||
marketplace: authenticatedMarketplace._id,
|
|
||||||
state: mapped.state,
|
|
||||||
totalAmount: mapped.totalAmount,
|
|
||||||
totalAmountWithTax: mapped.totalAmountWithTax,
|
|
||||||
shippingAmount: mapped.shippingAmount,
|
|
||||||
shippingAmountWithTax: mapped.shippingAmountWithTax,
|
|
||||||
grandTotalAmount: mapped.grandTotalAmount,
|
|
||||||
totalTaxAmount: mapped.totalTaxAmount,
|
|
||||||
},
|
|
||||||
user,
|
|
||||||
});
|
|
||||||
results.push({ externalId: mapped.externalId, action: 'created', id: salesOrder._id });
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn(`Failed to process order: ${err.message}`);
|
|
||||||
results.push({ externalId: externalOrder.id, action: 'error', error: err.message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
`Order sync complete for marketplace ${marketplace.name}: ${results.length} processed`
|
|
||||||
);
|
|
||||||
|
|
||||||
await recalculateMarketplaceState(marketplace, user);
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(
|
|
||||||
`Background syncOrders failed for marketplace "${marketplace.name}": ${err.message}`
|
|
||||||
);
|
|
||||||
await recalculateMarketplaceState(marketplace, user);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
work();
|
|
||||||
}
|
|
||||||
209
src/keycloak.js
209
src/keycloak.js
@ -1,22 +1,23 @@
|
|||||||
/**
|
import Keycloak from 'keycloak-connect';
|
||||||
* Authentication middleware - uses Redis session store.
|
import session from 'express-session';
|
||||||
* Keycloak is used only for login/refresh; session validation is done via Redis.
|
|
||||||
*/
|
|
||||||
import config, { getEnvironment } from './config.js';
|
import config, { getEnvironment } from './config.js';
|
||||||
|
import axios from 'axios';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
import log4js from 'log4js';
|
import log4js from 'log4js';
|
||||||
import NodeCache from 'node-cache';
|
import NodeCache from 'node-cache';
|
||||||
import bcrypt from 'bcrypt';
|
|
||||||
import { userModel } from './database/schemas/management/user.schema.js';
|
import { userModel } from './database/schemas/management/user.schema.js';
|
||||||
import { appPasswordModel } from './database/schemas/management/apppassword.schema.js';
|
|
||||||
import { getObject } from './database/database.js';
|
import { getObject } from './database/database.js';
|
||||||
import { hostModel } from './database/schemas/management/host.schema.js';
|
import { hostModel } from './database/schemas/management/host.schema.js';
|
||||||
import { getSession, lookupUserByToken } from './services/misc/auth.js';
|
|
||||||
|
|
||||||
const logger = log4js.getLogger('Keycloak');
|
const logger = log4js.getLogger('Keycloak');
|
||||||
logger.level = config.server.logLevel || 'info';
|
logger.level = config.server.logLevel || 'info';
|
||||||
|
|
||||||
const userCache = new NodeCache({ stdTTL: 300 });
|
dotenv.config();
|
||||||
|
|
||||||
|
// Initialize NodeCache with 5-minute TTL
|
||||||
|
const userCache = new NodeCache({ stdTTL: 300 }); // 300 seconds = 5 minutes
|
||||||
|
|
||||||
|
// Cache event listeners for monitoring
|
||||||
userCache.on('expired', (key, value) => {
|
userCache.on('expired', (key, value) => {
|
||||||
logger.debug(`Cache entry expired: ${key}`);
|
logger.debug(`Cache entry expired: ${key}`);
|
||||||
});
|
});
|
||||||
@ -25,18 +26,22 @@ userCache.on('flush', () => {
|
|||||||
logger.info('Cache flushed');
|
logger.info('Cache flushed');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// User lookup function with caching
|
||||||
const lookupUser = async (preferredUsername) => {
|
const lookupUser = async (preferredUsername) => {
|
||||||
try {
|
try {
|
||||||
|
// Check cache first
|
||||||
const cachedUser = userCache.get(preferredUsername);
|
const cachedUser = userCache.get(preferredUsername);
|
||||||
if (cachedUser) {
|
if (cachedUser) {
|
||||||
logger.debug(`User found in cache: ${preferredUsername}`);
|
logger.debug(`User found in cache: ${preferredUsername}`);
|
||||||
return cachedUser;
|
return cachedUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If not in cache, query database
|
||||||
logger.debug(`User not in cache, querying database: ${preferredUsername}`);
|
logger.debug(`User not in cache, querying database: ${preferredUsername}`);
|
||||||
const user = await userModel.findOne({ username: preferredUsername });
|
const user = await userModel.findOne({ username: preferredUsername });
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
|
// Store in cache
|
||||||
userCache.set(preferredUsername, user);
|
userCache.set(preferredUsername, user);
|
||||||
logger.debug(`User stored in cache: ${preferredUsername}`);
|
logger.debug(`User stored in cache: ${preferredUsername}`);
|
||||||
return user;
|
return user;
|
||||||
@ -50,130 +55,121 @@ const lookupUser = async (preferredUsername) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
// Initialize Keycloak
|
||||||
* Middleware to check if the user is authenticated.
|
const keycloakConfig = {
|
||||||
* Supports: 1) Bearer token (Redis session), 2) Bearer token (email-render JWT for Puppeteer),
|
realm: config.auth.keycloak.realm,
|
||||||
* 3) x-host-id + x-auth-code (host auth)
|
'auth-server-url': config.auth.keycloak.url,
|
||||||
*/
|
'ssl-required': getEnvironment() === 'production' ? 'external' : 'none',
|
||||||
|
resource: config.auth.keycloak.clientId,
|
||||||
|
'confidential-port': 0,
|
||||||
|
'bearer-only': true,
|
||||||
|
'public-client': false,
|
||||||
|
'use-resource-role-mappings': true,
|
||||||
|
'verify-token-audience': true,
|
||||||
|
credentials: {
|
||||||
|
secret: process.env.KEYCLOAK_CLIENT_SECRET,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const memoryStore = new session.MemoryStore();
|
||||||
|
|
||||||
|
var expressSession = session({
|
||||||
|
secret: process.env.SESSION_SECRET || 'n00Dl3s23!',
|
||||||
|
resave: false,
|
||||||
|
saveUninitialized: true, // Set this to true to ensure session is initialized
|
||||||
|
store: memoryStore,
|
||||||
|
cookie: {
|
||||||
|
maxAge: 1800000, // 30 minutes
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
var keycloak = new Keycloak({ store: memoryStore }, keycloakConfig);
|
||||||
|
|
||||||
|
// Custom middleware to check if the user is authenticated
|
||||||
const isAuthenticated = async (req, res, next) => {
|
const isAuthenticated = async (req, res, next) => {
|
||||||
|
let token = null;
|
||||||
|
|
||||||
const authHeader = req.headers.authorization || req.headers.Authorization;
|
const authHeader = req.headers.authorization || req.headers.Authorization;
|
||||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||||
const token = authHeader.substring(7);
|
token = authHeader.substring(7);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const session = await getSession(token);
|
// Verify token with Keycloak introspection endpoint
|
||||||
if (session && session.expiresAt > Date.now()) {
|
const response = await axios.post(
|
||||||
req.user = session.user;
|
`${config.auth.keycloak.url}/realms/${config.auth.keycloak.realm}/protocol/openid-connect/token/introspect`,
|
||||||
req.session = session;
|
new URLSearchParams({
|
||||||
return next();
|
token: token,
|
||||||
|
client_id: config.auth.keycloak.clientId,
|
||||||
|
client_secret: process.env.KEYCLOAK_CLIENT_SECRET,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const introspection = response.data;
|
||||||
|
if (!introspection.active) {
|
||||||
|
logger.info('Token is not active');
|
||||||
|
logger.debug('Token:', token);
|
||||||
|
return res.status(401).json({ error: 'Session Inactive', code: 'UNAUTHORIZED' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try email-render JWT (short-lived token for Puppeteer email notifications)
|
|
||||||
const user = await lookupUserByToken(token);
|
|
||||||
if (user) {
|
|
||||||
req.user = user;
|
|
||||||
req.session = { user };
|
|
||||||
return next();
|
return next();
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Session lookup error:', error.message);
|
logger.error('Token verification error:', error.message);
|
||||||
|
return res.status(401).json({ error: 'Verification Error', code: 'UNAUTHORIZED' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('Checking host authentication', req.headers);
|
||||||
|
|
||||||
const hostId = req.headers['x-host-id'];
|
const hostId = req.headers['x-host-id'];
|
||||||
const authCode = req.headers['x-auth-code'];
|
const authCode = req.headers['x-auth-code'];
|
||||||
if (hostId && authCode) {
|
if (hostId && authCode) {
|
||||||
const host = await getObject({ model: hostModel, id: hostId });
|
const host = await getObject({ model: hostModel, id: hostId });
|
||||||
if (host && host.authCode === authCode) {
|
if (host && host.authCode == authCode) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return res.status(401).json({ error: 'Not Authenticated', code: 'UNAUTHORIZED' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to session-based authentication
|
||||||
|
console.log('Using session token');
|
||||||
|
if (req.session && req.session['keycloak-token']) {
|
||||||
|
const sessionToken = req.session['keycloak-token'];
|
||||||
|
if (sessionToken.expires_at > new Date().getTime()) {
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
logger.debug('Not authenticated', { hostId, authCode }, 'req.headers', req.headers);
|
|
||||||
return res.status(401).json({ error: 'Not Authenticated', code: 'UNAUTHORIZED' });
|
return res.status(401).json({ error: 'Not Authenticated', code: 'UNAUTHORIZED' });
|
||||||
};
|
};
|
||||||
|
|
||||||
const authenticateWithAppPassword = async (username, secret) => {
|
// Helper function to extract roles from token
|
||||||
if (!username || !secret) return null;
|
function extractRoles(token) {
|
||||||
|
const roles = [];
|
||||||
|
|
||||||
const user = await userModel.findOne({ username }).lean();
|
// Extract realm roles
|
||||||
if (!user) return null;
|
if (token.realm_access && token.realm_access.roles) {
|
||||||
|
roles.push(...token.realm_access.roles);
|
||||||
|
}
|
||||||
|
|
||||||
const appPasswords = await appPasswordModel
|
// Extract client roles
|
||||||
.find({ user: user._id, active: true })
|
if (token.resource_access) {
|
||||||
.select('+secret')
|
for (const client in token.resource_access) {
|
||||||
.lean();
|
if (token.resource_access[client].roles) {
|
||||||
|
roles.push(...token.resource_access[client].roles.map((role) => `${client}:${role}`));
|
||||||
for (const appPassword of appPasswords) {
|
}
|
||||||
const storedHash = appPassword.secret;
|
|
||||||
if (storedHash && (await bcrypt.compare(secret, storedHash))) {
|
|
||||||
return user;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return roles;
|
||||||
};
|
}
|
||||||
|
|
||||||
const isAppAuthenticated = async (req, res, next) => {
|
|
||||||
const authHeader = req.headers.authorization || req.headers.Authorization;
|
|
||||||
const apiKey = req.headers['x-api-key'];
|
|
||||||
const userParam = req.params.username;
|
|
||||||
const passwordParam = req.params.password;
|
|
||||||
|
|
||||||
logger.debug('App authentication request', {
|
|
||||||
hasBasicAuth: authHeader?.startsWith('Basic ') === true,
|
|
||||||
hasApiKey: Boolean(apiKey),
|
|
||||||
hasPasswordParam: Boolean(passwordParam),
|
|
||||||
userParam,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Supports HTTP Basic Auth (username + app password secret)
|
|
||||||
if (authHeader?.startsWith('Basic ')) {
|
|
||||||
try {
|
|
||||||
logger.debug('Basic auth header present');
|
|
||||||
const base64Credentials = authHeader.substring(6);
|
|
||||||
const credentials = Buffer.from(base64Credentials, 'base64').toString('utf-8');
|
|
||||||
const colonIndex = credentials.indexOf(':');
|
|
||||||
const username = credentials.substring(0, colonIndex).trim();
|
|
||||||
const secret = credentials.substring(colonIndex + 1).trim();
|
|
||||||
const user = await authenticateWithAppPassword(username, secret);
|
|
||||||
if (user) {
|
|
||||||
req.user = user;
|
|
||||||
req.session = { user };
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Basic auth error:', error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const appPassword = apiKey || passwordParam;
|
|
||||||
if (appPassword && userParam) {
|
|
||||||
logger.debug('App password and username present', { userParam });
|
|
||||||
const user = await authenticateWithAppPassword(userParam, appPassword);
|
|
||||||
if (user) {
|
|
||||||
req.user = user;
|
|
||||||
req.session = { user };
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return res.status(401).json({ error: 'Not Authenticated', code: 'UNAUTHORIZED' });
|
|
||||||
};
|
|
||||||
|
|
||||||
const isAppQueryAuthenticated = async (req, res, next) => {
|
|
||||||
try {
|
|
||||||
const username = typeof req.query.u === 'string' ? req.query.u.trim() : '';
|
|
||||||
const secret = typeof req.query.p === 'string' ? req.query.p : '';
|
|
||||||
const user = await authenticateWithAppPassword(username, secret);
|
|
||||||
if (user) {
|
|
||||||
req.user = user;
|
|
||||||
req.session = { user };
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Query auth error:', error.message);
|
|
||||||
}
|
|
||||||
return res.status(401).json({ error: 'Not Authenticated', code: 'UNAUTHORIZED' });
|
|
||||||
};
|
|
||||||
|
|
||||||
|
// Cache management utility functions
|
||||||
const clearUserCache = () => {
|
const clearUserCache = () => {
|
||||||
userCache.flushAll();
|
userCache.flushAll();
|
||||||
logger.info('User cache cleared');
|
logger.info('User cache cleared');
|
||||||
@ -189,12 +185,11 @@ const removeUserFromCache = (username) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
keycloak,
|
||||||
|
expressSession,
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
isAppAuthenticated,
|
|
||||||
isAppQueryAuthenticated,
|
|
||||||
lookupUser,
|
lookupUser,
|
||||||
clearUserCache,
|
clearUserCache,
|
||||||
getUserCacheStats,
|
getUserCacheStats,
|
||||||
removeUserFromCache,
|
removeUserFromCache,
|
||||||
getEnvironment,
|
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,151 +0,0 @@
|
|||||||
/**
|
|
||||||
* Worker thread for sending email notifications asynchronously.
|
|
||||||
* Receives payloads from the main thread and performs Puppeteer render + nodemailer send.
|
|
||||||
*/
|
|
||||||
import { parentPort } from 'worker_threads';
|
|
||||||
import puppeteer from 'puppeteer';
|
|
||||||
import nodemailer from 'nodemailer';
|
|
||||||
import log4js from 'log4js';
|
|
||||||
import config from './config.js';
|
|
||||||
|
|
||||||
const baseUrl = (urlClient) => (urlClient || 'http://localhost:3000').replace(/\/$/, '');
|
|
||||||
|
|
||||||
async function fetchAndInlineStyles(html, urlClient) {
|
|
||||||
const base = baseUrl(urlClient);
|
|
||||||
const linkMatches = [...html.matchAll(/<link[^>]+>/g)];
|
|
||||||
const stylesheetLinks = linkMatches
|
|
||||||
.map((m) => {
|
|
||||||
const tag = m[0];
|
|
||||||
if (!/rel=["']stylesheet["']/i.test(tag)) return null;
|
|
||||||
const hrefMatch = tag.match(/href=["']([^"']+)["']/);
|
|
||||||
return hrefMatch ? { tag, href: hrefMatch[1] } : null;
|
|
||||||
})
|
|
||||||
.filter(Boolean);
|
|
||||||
let inlined = html;
|
|
||||||
for (const { tag, href } of stylesheetLinks) {
|
|
||||||
const url = href.startsWith('http') ? href : `${base}${href.startsWith('/') ? '' : '/'}${href}`;
|
|
||||||
try {
|
|
||||||
const res = await fetch(url);
|
|
||||||
if (res.ok) {
|
|
||||||
const css = await res.text();
|
|
||||||
inlined = inlined.replace(tag, `<style>${css}</style>`);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
logger.trace('Could not fetch stylesheet:', url, e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return inlined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const logger = log4js.getLogger('MailWorker');
|
|
||||||
logger.level = config.server.logLevel;
|
|
||||||
|
|
||||||
async function sendEmail(payload) {
|
|
||||||
const {
|
|
||||||
email,
|
|
||||||
title,
|
|
||||||
message,
|
|
||||||
type,
|
|
||||||
metadata,
|
|
||||||
smtpConfig,
|
|
||||||
urlClient,
|
|
||||||
createdAt,
|
|
||||||
updatedAt,
|
|
||||||
authCode,
|
|
||||||
} = payload;
|
|
||||||
|
|
||||||
if (!email || !smtpConfig?.host) {
|
|
||||||
logger.warn('Missing email or SMTP config, skipping...');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
title: title || '',
|
|
||||||
message: message || '',
|
|
||||||
type: type || 'info',
|
|
||||||
email: email || '',
|
|
||||||
createdAt: createdAt || new Date(),
|
|
||||||
updatedAt: updatedAt || new Date(),
|
|
||||||
authCode: authCode || '',
|
|
||||||
metadata: JSON.stringify(metadata || {}),
|
|
||||||
});
|
|
||||||
const templateUrl = `${baseUrl(urlClient)}/email/notification?${params.toString()}`;
|
|
||||||
|
|
||||||
logger.debug('Rendering template...');
|
|
||||||
logger.trace('Template URL:', templateUrl);
|
|
||||||
|
|
||||||
let html = '';
|
|
||||||
let browser;
|
|
||||||
try {
|
|
||||||
browser = await puppeteer.launch({
|
|
||||||
headless: 'new',
|
|
||||||
args: [
|
|
||||||
'--no-sandbox',
|
|
||||||
'--disable-setuid-sandbox',
|
|
||||||
'--disable-features=SameSiteByDefaultCookies',
|
|
||||||
],
|
|
||||||
});
|
|
||||||
const page = await browser.newPage();
|
|
||||||
page.on('console', (msg) => {
|
|
||||||
const text = msg.text();
|
|
||||||
const type = msg.type();
|
|
||||||
logger.trace(`Puppeteer [${type}]: ${text}`);
|
|
||||||
});
|
|
||||||
await page.goto(templateUrl, { waitUntil: 'networkidle0', timeout: 30000 });
|
|
||||||
await page.waitForSelector('#email-notification-root[data-rendered="true"]', { timeout: 5000 });
|
|
||||||
// Wait for Ant Design CSS-in-JS to finish injecting styles
|
|
||||||
logger.debug('Waiting for 1.5 seconds for page to render...');
|
|
||||||
await new Promise((r) => setTimeout(r, 1500));
|
|
||||||
html = await page.evaluate(() => {
|
|
||||||
const root = document.getElementById('email-notification-root');
|
|
||||||
if (!root) return document.documentElement.outerHTML;
|
|
||||||
const origin = document.location.origin;
|
|
||||||
const styleTags = Array.from(document.querySelectorAll('style'))
|
|
||||||
.map((s) => s.outerHTML)
|
|
||||||
.join('\n');
|
|
||||||
const linkTags = Array.from(document.querySelectorAll('link[rel="stylesheet"]'))
|
|
||||||
.map((link) => {
|
|
||||||
const href = link.getAttribute('href');
|
|
||||||
const abs = href?.startsWith('http')
|
|
||||||
? href
|
|
||||||
: `${origin}${href?.startsWith('/') ? '' : '/'}${href || ''}`;
|
|
||||||
return `<link rel="stylesheet" href="${abs}">`;
|
|
||||||
})
|
|
||||||
.join('\n');
|
|
||||||
return `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">${styleTags}${linkTags}</head><body>${root.outerHTML}</body></html>`;
|
|
||||||
});
|
|
||||||
html = await fetchAndInlineStyles(html, urlClient);
|
|
||||||
} catch (err) {
|
|
||||||
logger.error('MailWorker: Puppeteer error', err.message);
|
|
||||||
html = `<div style="font-family:sans-serif;padding:20px"><h2>${title || 'Notification'}</h2><p>${message || ''}</p></div>`;
|
|
||||||
} finally {
|
|
||||||
if (browser) await browser.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
const transporter = nodemailer.createTransport({
|
|
||||||
host: smtpConfig.host,
|
|
||||||
port: smtpConfig.port || 587,
|
|
||||||
secure: smtpConfig.secure || false,
|
|
||||||
auth: smtpConfig.auth?.user ? smtpConfig.auth : undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const subject = title ? `${title} - FarmControl` : 'FarmControl Notification';
|
|
||||||
|
|
||||||
const mailOptions = {
|
|
||||||
from: smtpConfig.from || 'FarmControl <noreply@tombutcher.work>',
|
|
||||||
to: email,
|
|
||||||
subject: subject,
|
|
||||||
html,
|
|
||||||
};
|
|
||||||
|
|
||||||
logger.debug('Sending email...');
|
|
||||||
logger.trace('Mail options:', mailOptions);
|
|
||||||
const info = await transporter.sendMail(mailOptions);
|
|
||||||
logger.debug('Email sent successfully.');
|
|
||||||
}
|
|
||||||
|
|
||||||
parentPort.on('message', (payload) => {
|
|
||||||
sendEmail(payload).catch((err) => {
|
|
||||||
logger.error('MailWorker: send failed', err.message);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,125 +0,0 @@
|
|||||||
import express from 'express';
|
|
||||||
import { isAuthenticated } from '../../keycloak.js';
|
|
||||||
import { getFilter, convertPropertiesString } from '../../utils.js';
|
|
||||||
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
const listAllowedFilters = [
|
|
||||||
'vendor',
|
|
||||||
'client',
|
|
||||||
'state',
|
|
||||||
'vendor._id',
|
|
||||||
'client._id',
|
|
||||||
'order',
|
|
||||||
'order._id',
|
|
||||||
'orderType',
|
|
||||||
'createdAt',
|
|
||||||
'updatedAt',
|
|
||||||
'_reference'
|
|
||||||
];
|
|
||||||
const propertiesAllowedFilters = [
|
|
||||||
'vendor',
|
|
||||||
'client',
|
|
||||||
'orderType',
|
|
||||||
'order',
|
|
||||||
'state.type',
|
|
||||||
'value',
|
|
||||||
'vendor._id',
|
|
||||||
'client._id',
|
|
||||||
];
|
|
||||||
import {
|
|
||||||
listInvoicesRouteHandler,
|
|
||||||
getInvoiceRouteHandler,
|
|
||||||
editInvoiceRouteHandler,
|
|
||||||
editMultipleInvoicesRouteHandler,
|
|
||||||
newInvoiceRouteHandler,
|
|
||||||
deleteInvoiceRouteHandler,
|
|
||||||
listInvoicesByPropertiesRouteHandler,
|
|
||||||
getInvoiceStatsRouteHandler,
|
|
||||||
getInvoiceHistoryRouteHandler,
|
|
||||||
acknowledgeInvoiceRouteHandler,
|
|
||||||
cancelInvoiceRouteHandler,
|
|
||||||
postInvoiceRouteHandler,
|
|
||||||
searchInvoicesRouteHandler,
|
|
||||||
getInvoicePropertyValuesRouteHandler,
|
|
||||||
|
|
||||||
getInvoiceNeighborsRouteHandler
|
|
||||||
} from '../../services/finance/invoices.js';
|
|
||||||
|
|
||||||
// list of invoices
|
|
||||||
router.get('/', isAuthenticated, (req, res) => {
|
|
||||||
const { page, limit, property, search, sort, order } = req.query;
|
|
||||||
const filter = getFilter(req.query, listAllowedFilters);
|
|
||||||
listInvoicesRouteHandler(req, res, page, limit, property, filter, search, sort, order);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get('/properties', isAuthenticated, (req, res) => {
|
|
||||||
let properties = convertPropertiesString(req.query.properties);
|
|
||||||
const filter = getFilter(req.query, propertiesAllowedFilters, false);
|
|
||||||
var masterFilter = {};
|
|
||||||
if (req.query.masterFilter) {
|
|
||||||
masterFilter = JSON.parse(req.query.masterFilter);
|
|
||||||
}
|
|
||||||
listInvoicesByPropertiesRouteHandler(req, res, properties, filter, masterFilter);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get('/values', isAuthenticated, (req, res) => {
|
|
||||||
const { property } = req.query;
|
|
||||||
getInvoicePropertyValuesRouteHandler(req, res, property);
|
|
||||||
});
|
|
||||||
router.get('/search', isAuthenticated, (req, res) => {
|
|
||||||
const { search } = req.query;
|
|
||||||
searchInvoicesRouteHandler(req, res, search);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
router.post('/', isAuthenticated, (req, res) => {
|
|
||||||
newInvoiceRouteHandler(req, res);
|
|
||||||
});
|
|
||||||
|
|
||||||
// get invoice stats
|
|
||||||
router.get('/stats', isAuthenticated, (req, res) => {
|
|
||||||
getInvoiceStatsRouteHandler(req, res);
|
|
||||||
});
|
|
||||||
|
|
||||||
// get invoice history
|
|
||||||
router.get('/history', isAuthenticated, (req, res) => {
|
|
||||||
getInvoiceHistoryRouteHandler(req, res);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get('/neighbors', isAuthenticated, (req, res) => {
|
|
||||||
const { property, search, sort, order, id } = req.query;
|
|
||||||
const filter = getFilter(req.query, listAllowedFilters);
|
|
||||||
getInvoiceNeighborsRouteHandler(req, res, property, filter, search, sort, order, id);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get('/:id', isAuthenticated, (req, res) => {
|
|
||||||
getInvoiceRouteHandler(req, res);
|
|
||||||
});
|
|
||||||
|
|
||||||
// update multiple invoices
|
|
||||||
router.put('/', isAuthenticated, async (req, res) => {
|
|
||||||
editMultipleInvoicesRouteHandler(req, res);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.put('/:id', isAuthenticated, async (req, res) => {
|
|
||||||
editInvoiceRouteHandler(req, res);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.delete('/:id', isAuthenticated, async (req, res) => {
|
|
||||||
deleteInvoiceRouteHandler(req, res);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post('/:id/post', isAuthenticated, async (req, res) => {
|
|
||||||
postInvoiceRouteHandler(req, res);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post('/:id/acknowledge', isAuthenticated, async (req, res) => {
|
|
||||||
acknowledgeInvoiceRouteHandler(req, res);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post('/:id/cancel', isAuthenticated, async (req, res) => {
|
|
||||||
cancelInvoiceRouteHandler(req, res);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
@ -1,130 +0,0 @@
|
|||||||
import express from 'express';
|
|
||||||
import { isAuthenticated } from '../../keycloak.js';
|
|
||||||
import { getFilter, convertPropertiesString } from '../../utils.js';
|
|
||||||
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
const listAllowedFilters = [
|
|
||||||
'vendor',
|
|
||||||
'client',
|
|
||||||
'state',
|
|
||||||
'vendor._id',
|
|
||||||
'client._id',
|
|
||||||
'invoice',
|
|
||||||
'invoice._id',
|
|
||||||
'createdAt',
|
|
||||||
'updatedAt',
|
|
||||||
'_reference'
|
|
||||||
];
|
|
||||||
const propertiesAllowedFilters = [
|
|
||||||
'vendor',
|
|
||||||
'client',
|
|
||||||
'invoice',
|
|
||||||
'state.type',
|
|
||||||
'value',
|
|
||||||
'vendor._id',
|
|
||||||
'client._id',
|
|
||||||
'invoice._id',
|
|
||||||
];
|
|
||||||
import {
|
|
||||||
listPaymentsRouteHandler,
|
|
||||||
getPaymentRouteHandler,
|
|
||||||
editPaymentRouteHandler,
|
|
||||||
editMultiplePaymentsRouteHandler,
|
|
||||||
newPaymentRouteHandler,
|
|
||||||
deletePaymentRouteHandler,
|
|
||||||
listPaymentsByPropertiesRouteHandler,
|
|
||||||
getPaymentStatsRouteHandler,
|
|
||||||
getPaymentHistoryRouteHandler,
|
|
||||||
postPaymentRouteHandler,
|
|
||||||
authorisePaymentRouteHandler,
|
|
||||||
declinePaymentRouteHandler,
|
|
||||||
cancelPaymentRouteHandler,
|
|
||||||
searchPaymentsRouteHandler,
|
|
||||||
getPaymentPropertyValuesRouteHandler,
|
|
||||||
|
|
||||||
getPaymentNeighborsRouteHandler
|
|
||||||
} from '../../services/finance/payments.js';
|
|
||||||
|
|
||||||
// list of payments
|
|
||||||
router.get('/', isAuthenticated, (req, res) => {
|
|
||||||
const { page, limit, property, search, sort, order } = req.query;
|
|
||||||
const filter = getFilter(req.query, listAllowedFilters);
|
|
||||||
listPaymentsRouteHandler(req, res, page, limit, property, filter, search, sort, order);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get('/properties', isAuthenticated, (req, res) => {
|
|
||||||
let properties = convertPropertiesString(req.query.properties);
|
|
||||||
const filter = getFilter(req.query, propertiesAllowedFilters, false);
|
|
||||||
var masterFilter = {};
|
|
||||||
if (req.query.masterFilter) {
|
|
||||||
masterFilter = JSON.parse(req.query.masterFilter);
|
|
||||||
}
|
|
||||||
listPaymentsByPropertiesRouteHandler(req, res, properties, filter, masterFilter);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get('/values', isAuthenticated, (req, res) => {
|
|
||||||
const { property } = req.query;
|
|
||||||
getPaymentPropertyValuesRouteHandler(req, res, property);
|
|
||||||
});
|
|
||||||
router.get('/search', isAuthenticated, (req, res) => {
|
|
||||||
const { search } = req.query;
|
|
||||||
searchPaymentsRouteHandler(req, res, search);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
router.post('/', isAuthenticated, (req, res) => {
|
|
||||||
newPaymentRouteHandler(req, res);
|
|
||||||
});
|
|
||||||
|
|
||||||
// get payment stats
|
|
||||||
router.get('/stats', isAuthenticated, (req, res) => {
|
|
||||||
getPaymentStatsRouteHandler(req, res);
|
|
||||||
});
|
|
||||||
|
|
||||||
// get payment history
|
|
||||||
router.get('/history', isAuthenticated, (req, res) => {
|
|
||||||
getPaymentHistoryRouteHandler(req, res);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get('/neighbors', isAuthenticated, (req, res) => {
|
|
||||||
const { property, search, sort, order, id } = req.query;
|
|
||||||
const filter = getFilter(req.query, listAllowedFilters);
|
|
||||||
getPaymentNeighborsRouteHandler(req, res, property, filter, search, sort, order, id);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get('/:id', isAuthenticated, (req, res) => {
|
|
||||||
getPaymentRouteHandler(req, res);
|
|
||||||
});
|
|
||||||
|
|
||||||
// update multiple payments
|
|
||||||
router.put('/', isAuthenticated, async (req, res) => {
|
|
||||||
editMultiplePaymentsRouteHandler(req, res);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.put('/:id', isAuthenticated, async (req, res) => {
|
|
||||||
editPaymentRouteHandler(req, res);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.delete('/:id', isAuthenticated, async (req, res) => {
|
|
||||||
deletePaymentRouteHandler(req, res);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post('/:id/post', isAuthenticated, async (req, res) => {
|
|
||||||
postPaymentRouteHandler(req, res);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post('/:id/authorise', isAuthenticated, async (req, res) => {
|
|
||||||
authorisePaymentRouteHandler(req, res);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post('/:id/decline', isAuthenticated, async (req, res) => {
|
|
||||||
declinePaymentRouteHandler(req, res);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post('/:id/cancel', isAuthenticated, async (req, res) => {
|
|
||||||
cancelPaymentRouteHandler(req, res);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
|
|
||||||
@ -1,34 +1,24 @@
|
|||||||
import userRoutes from './management/users.js';
|
import userRoutes from './management/users.js';
|
||||||
import appPasswordRoutes from './management/apppasswords.js';
|
|
||||||
import fileRoutes from './management/files.js';
|
import fileRoutes from './management/files.js';
|
||||||
import authRoutes from './misc/auth.js';
|
import authRoutes from './misc/auth.js';
|
||||||
import printerRoutes from './production/printers.js';
|
import printerRoutes from './production/printers.js';
|
||||||
import printerProfileRoutes from './production/printerprofiles.js';
|
|
||||||
import filamentProfileRoutes from './production/filamentprofiles.js';
|
|
||||||
import hostRoutes from './management/hosts.js';
|
import hostRoutes from './management/hosts.js';
|
||||||
import jobRoutes from './production/jobs.js';
|
import jobRoutes from './production/jobs.js';
|
||||||
import subJobRoutes from './production/subjobs.js';
|
import subJobRoutes from './production/subjobs.js';
|
||||||
import gcodeFileRoutes from './production/gcodefiles.js';
|
import gcodeFileRoutes from './production/gcodefiles.js';
|
||||||
import filamentRoutes from './management/filaments.js';
|
import filamentRoutes from './management/filaments.js';
|
||||||
import filamentSkuRoutes from './management/filamentskus.js';
|
|
||||||
import spotlightRoutes from './misc/spotlight.js';
|
import spotlightRoutes from './misc/spotlight.js';
|
||||||
import partRoutes from './management/parts.js';
|
import partRoutes from './management/parts.js';
|
||||||
import partSkuRoutes from './management/partskus.js';
|
|
||||||
import productRoutes from './management/products.js';
|
import productRoutes from './management/products.js';
|
||||||
import productCategoryRoutes from './management/productcategories.js';
|
|
||||||
import productSkuRoutes from './management/productskus.js';
|
|
||||||
import vendorRoutes from './management/vendors.js';
|
import vendorRoutes from './management/vendors.js';
|
||||||
import materialRoutes from './management/materials.js';
|
import materialRoutes from './management/materials.js';
|
||||||
import partStockRoutes from './inventory/partstocks.js';
|
import partStockRoutes from './inventory/partstocks.js';
|
||||||
import productStockRoutes from './inventory/productstocks.js';
|
|
||||||
import filamentStockRoutes from './inventory/filamentstocks.js';
|
import filamentStockRoutes from './inventory/filamentstocks.js';
|
||||||
import purchaseOrderRoutes from './inventory/purchaseorders.js';
|
import purchaseOrderRoutes from './inventory/purchaseorders.js';
|
||||||
import orderItemRoutes from './inventory/orderitems.js';
|
import orderItemRoutes from './inventory/orderitems.js';
|
||||||
import shipmentRoutes from './inventory/shipments.js';
|
import shipmentRoutes from './inventory/shipments.js';
|
||||||
import stockEventRoutes from './inventory/stockevents.js';
|
import stockEventRoutes from './inventory/stockevents.js';
|
||||||
import stockAuditRoutes from './inventory/stockaudits.js';
|
import stockAuditRoutes from './inventory/stockaudits.js';
|
||||||
import stockLocationRoutes from './inventory/stocklocations.js';
|
|
||||||
import stockTransferRoutes from './inventory/stocktransfers.js';
|
|
||||||
import auditLogRoutes from './management/auditlogs.js';
|
import auditLogRoutes from './management/auditlogs.js';
|
||||||
import noteTypeRoutes from './management/notetypes.js';
|
import noteTypeRoutes from './management/notetypes.js';
|
||||||
import documentSizesRoutes from './management/documentsizes.js';
|
import documentSizesRoutes from './management/documentsizes.js';
|
||||||
@ -38,58 +28,31 @@ import documentJobsRoutes from './management/documentjobs.js';
|
|||||||
import courierRoutes from './management/courier.js';
|
import courierRoutes from './management/courier.js';
|
||||||
import courierServiceRoutes from './management/courierservice.js';
|
import courierServiceRoutes from './management/courierservice.js';
|
||||||
import taxRateRoutes from './management/taxrates.js';
|
import taxRateRoutes from './management/taxrates.js';
|
||||||
import taxRecordRoutes from './finance/taxrecords.js';
|
import taxRecordRoutes from './management/taxrecords.js';
|
||||||
import invoiceRoutes from './finance/invoices.js';
|
|
||||||
import paymentRoutes from './finance/payments.js';
|
|
||||||
import clientRoutes from './sales/clients.js';
|
|
||||||
import salesOrderRoutes from './sales/salesorders.js';
|
|
||||||
import marketplaceRoutes from './sales/marketplaces.js';
|
|
||||||
import listingRoutes from './sales/listings.js';
|
|
||||||
import listingVarientRoutes from './sales/listingvarients.js';
|
|
||||||
import noteRoutes from './misc/notes.js';
|
import noteRoutes from './misc/notes.js';
|
||||||
import userNotifierRoutes from './misc/usernotifiers.js';
|
|
||||||
import notificationRoutes from './misc/notifications.js';
|
|
||||||
import odataRoutes from './misc/odata.js';
|
|
||||||
import rssRoutes from './misc/rss.js';
|
|
||||||
import excelRoutes from './misc/excel.js';
|
|
||||||
import csvRoutes from './misc/csv.js';
|
|
||||||
import appLaunchRoutes from './misc/applaunch.js';
|
|
||||||
import appUpdateRoutes from './misc/appupdate.js';
|
|
||||||
import serverRoutes from './misc/server.js';
|
|
||||||
import slicerRoutes from './misc/slicer.js';
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
userRoutes,
|
userRoutes,
|
||||||
appPasswordRoutes,
|
|
||||||
fileRoutes,
|
fileRoutes,
|
||||||
authRoutes,
|
authRoutes,
|
||||||
printerRoutes,
|
printerRoutes,
|
||||||
printerProfileRoutes,
|
|
||||||
filamentProfileRoutes,
|
|
||||||
hostRoutes,
|
hostRoutes,
|
||||||
jobRoutes,
|
jobRoutes,
|
||||||
subJobRoutes,
|
subJobRoutes,
|
||||||
gcodeFileRoutes,
|
gcodeFileRoutes,
|
||||||
filamentRoutes,
|
filamentRoutes,
|
||||||
filamentSkuRoutes,
|
|
||||||
spotlightRoutes,
|
spotlightRoutes,
|
||||||
partRoutes,
|
partRoutes,
|
||||||
partSkuRoutes,
|
|
||||||
productRoutes,
|
productRoutes,
|
||||||
productCategoryRoutes,
|
|
||||||
productSkuRoutes,
|
|
||||||
vendorRoutes,
|
vendorRoutes,
|
||||||
materialRoutes,
|
materialRoutes,
|
||||||
partStockRoutes,
|
partStockRoutes,
|
||||||
productStockRoutes,
|
|
||||||
filamentStockRoutes,
|
filamentStockRoutes,
|
||||||
purchaseOrderRoutes,
|
purchaseOrderRoutes,
|
||||||
orderItemRoutes,
|
orderItemRoutes,
|
||||||
shipmentRoutes,
|
shipmentRoutes,
|
||||||
stockEventRoutes,
|
stockEventRoutes,
|
||||||
stockAuditRoutes,
|
stockAuditRoutes,
|
||||||
stockLocationRoutes,
|
|
||||||
stockTransferRoutes,
|
|
||||||
auditLogRoutes,
|
auditLogRoutes,
|
||||||
noteTypeRoutes,
|
noteTypeRoutes,
|
||||||
noteRoutes,
|
noteRoutes,
|
||||||
@ -101,21 +64,4 @@ export {
|
|||||||
courierServiceRoutes,
|
courierServiceRoutes,
|
||||||
taxRateRoutes,
|
taxRateRoutes,
|
||||||
taxRecordRoutes,
|
taxRecordRoutes,
|
||||||
invoiceRoutes,
|
|
||||||
paymentRoutes,
|
|
||||||
clientRoutes,
|
|
||||||
salesOrderRoutes,
|
|
||||||
marketplaceRoutes,
|
|
||||||
listingRoutes,
|
|
||||||
listingVarientRoutes,
|
|
||||||
userNotifierRoutes,
|
|
||||||
notificationRoutes,
|
|
||||||
odataRoutes,
|
|
||||||
rssRoutes,
|
|
||||||
excelRoutes,
|
|
||||||
csvRoutes,
|
|
||||||
appLaunchRoutes,
|
|
||||||
appUpdateRoutes,
|
|
||||||
serverRoutes,
|
|
||||||
slicerRoutes,
|
|
||||||
};
|
};
|
||||||
|
|||||||
@ -3,54 +3,29 @@ import { isAuthenticated } from '../../keycloak.js';
|
|||||||
import { getFilter, convertPropertiesString } from '../../utils.js';
|
import { getFilter, convertPropertiesString } from '../../utils.js';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
const listAllowedFilters = [
|
|
||||||
'filament',
|
|
||||||
'filament._id',
|
|
||||||
'filamentSku',
|
|
||||||
'state',
|
|
||||||
'startingWeight',
|
|
||||||
'currentWeight',
|
|
||||||
'filamentSku._id',
|
|
||||||
'stockLocation',
|
|
||||||
'stockLocation._id',
|
|
||||||
'createdAt',
|
|
||||||
'updatedAt',
|
|
||||||
'_reference'
|
|
||||||
];
|
|
||||||
const propertiesAllowedFilters = [
|
|
||||||
'filament',
|
|
||||||
'filament._id',
|
|
||||||
'filamentSku',
|
|
||||||
'state.type',
|
|
||||||
'filamentSku._id',
|
|
||||||
];
|
|
||||||
import {
|
import {
|
||||||
listFilamentStocksRouteHandler,
|
listFilamentStocksRouteHandler,
|
||||||
getFilamentStockRouteHandler,
|
getFilamentStockRouteHandler,
|
||||||
editFilamentStockRouteHandler,
|
editFilamentStockRouteHandler,
|
||||||
editMultipleFilamentStocksRouteHandler,
|
|
||||||
newFilamentStockRouteHandler,
|
newFilamentStockRouteHandler,
|
||||||
deleteFilamentStockRouteHandler,
|
deleteFilamentStockRouteHandler,
|
||||||
listFilamentStocksByPropertiesRouteHandler,
|
listFilamentStocksByPropertiesRouteHandler,
|
||||||
getFilamentStockStatsRouteHandler,
|
getFilamentStockStatsRouteHandler,
|
||||||
getFilamentStockHistoryRouteHandler,
|
getFilamentStockHistoryRouteHandler,
|
||||||
searchFilamentStocksRouteHandler,
|
|
||||||
getFilamentStockPropertyValuesRouteHandler,
|
|
||||||
|
|
||||||
getFilamentStockNeighborsRouteHandler
|
|
||||||
} from '../../services/inventory/filamentstocks.js';
|
} from '../../services/inventory/filamentstocks.js';
|
||||||
|
|
||||||
// list of filament stocks
|
// list of filament stocks
|
||||||
router.get('/', isAuthenticated, (req, res) => {
|
router.get('/', isAuthenticated, (req, res) => {
|
||||||
const { page, limit, property, search, sort, order } = req.query;
|
const { page, limit, property, search, sort, order } = req.query;
|
||||||
const filter = getFilter(req.query, listAllowedFilters);
|
const allowedFilters = ['filament', 'state', 'startingWeight', 'currentWeight', 'filament._id'];
|
||||||
|
const filter = getFilter(req.query, allowedFilters);
|
||||||
listFilamentStocksRouteHandler(req, res, page, limit, property, filter, search, sort, order);
|
listFilamentStocksRouteHandler(req, res, page, limit, property, filter, search, sort, order);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/properties', isAuthenticated, (req, res) => {
|
router.get('/properties', isAuthenticated, (req, res) => {
|
||||||
let properties = convertPropertiesString(req.query.properties);
|
let properties = convertPropertiesString(req.query.properties);
|
||||||
const filter = getFilter(req.query, propertiesAllowedFilters, false);
|
const allowedFilters = ['filament', 'state.type'];
|
||||||
|
const filter = getFilter(req.query, allowedFilters, false);
|
||||||
var masterFilter = {};
|
var masterFilter = {};
|
||||||
if (req.query.masterFilter) {
|
if (req.query.masterFilter) {
|
||||||
masterFilter = JSON.parse(req.query.masterFilter);
|
masterFilter = JSON.parse(req.query.masterFilter);
|
||||||
@ -58,16 +33,6 @@ router.get('/properties', isAuthenticated, (req, res) => {
|
|||||||
listFilamentStocksByPropertiesRouteHandler(req, res, properties, filter, masterFilter);
|
listFilamentStocksByPropertiesRouteHandler(req, res, properties, filter, masterFilter);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/values', isAuthenticated, (req, res) => {
|
|
||||||
const { property } = req.query;
|
|
||||||
getFilamentStockPropertyValuesRouteHandler(req, res, property);
|
|
||||||
});
|
|
||||||
router.get('/search', isAuthenticated, (req, res) => {
|
|
||||||
const { search } = req.query;
|
|
||||||
searchFilamentStocksRouteHandler(req, res, search);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
router.post('/', isAuthenticated, (req, res) => {
|
router.post('/', isAuthenticated, (req, res) => {
|
||||||
newFilamentStockRouteHandler(req, res);
|
newFilamentStockRouteHandler(req, res);
|
||||||
});
|
});
|
||||||
@ -82,21 +47,10 @@ router.get('/history', isAuthenticated, (req, res) => {
|
|||||||
getFilamentStockHistoryRouteHandler(req, res);
|
getFilamentStockHistoryRouteHandler(req, res);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/neighbors', isAuthenticated, (req, res) => {
|
|
||||||
const { property, search, sort, order, id } = req.query;
|
|
||||||
const filter = getFilter(req.query, listAllowedFilters);
|
|
||||||
getFilamentStockNeighborsRouteHandler(req, res, property, filter, search, sort, order, id);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get('/:id', isAuthenticated, (req, res) => {
|
router.get('/:id', isAuthenticated, (req, res) => {
|
||||||
getFilamentStockRouteHandler(req, res);
|
getFilamentStockRouteHandler(req, res);
|
||||||
});
|
});
|
||||||
|
|
||||||
// update multiple filament stocks
|
|
||||||
router.put('/', isAuthenticated, async (req, res) => {
|
|
||||||
editMultipleFilamentStocksRouteHandler(req, res);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.put('/:id', isAuthenticated, async (req, res) => {
|
router.put('/:id', isAuthenticated, async (req, res) => {
|
||||||
editFilamentStockRouteHandler(req, res);
|
editFilamentStockRouteHandler(req, res);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -3,59 +3,29 @@ import { isAuthenticated } from '../../keycloak.js';
|
|||||||
import { getFilter, convertPropertiesString } from '../../utils.js';
|
import { getFilter, convertPropertiesString } from '../../utils.js';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
const listAllowedFilters = [
|
|
||||||
'name',
|
|
||||||
'itemType',
|
|
||||||
'item',
|
|
||||||
'item._id',
|
|
||||||
'order',
|
|
||||||
'order._id',
|
|
||||||
'orderType',
|
|
||||||
'shipment',
|
|
||||||
'shipment._id',
|
|
||||||
'state',
|
|
||||||
'createdAt',
|
|
||||||
'updatedAt',
|
|
||||||
'_reference'
|
|
||||||
];
|
|
||||||
const propertiesAllowedFilters = [
|
|
||||||
'name',
|
|
||||||
'itemType',
|
|
||||||
'item',
|
|
||||||
'item._id',
|
|
||||||
'order',
|
|
||||||
'order._id',
|
|
||||||
'orderType',
|
|
||||||
'shipment',
|
|
||||||
'shipment._id',
|
|
||||||
];
|
|
||||||
import {
|
import {
|
||||||
listOrderItemsRouteHandler,
|
listOrderItemsRouteHandler,
|
||||||
getOrderItemRouteHandler,
|
getOrderItemRouteHandler,
|
||||||
editOrderItemRouteHandler,
|
editOrderItemRouteHandler,
|
||||||
editMultipleOrderItemsRouteHandler,
|
|
||||||
newOrderItemRouteHandler,
|
newOrderItemRouteHandler,
|
||||||
deleteOrderItemRouteHandler,
|
deleteOrderItemRouteHandler,
|
||||||
listOrderItemsByPropertiesRouteHandler,
|
listOrderItemsByPropertiesRouteHandler,
|
||||||
getOrderItemStatsRouteHandler,
|
getOrderItemStatsRouteHandler,
|
||||||
getOrderItemHistoryRouteHandler,
|
getOrderItemHistoryRouteHandler,
|
||||||
searchOrderItemsRouteHandler,
|
|
||||||
getOrderItemPropertyValuesRouteHandler,
|
|
||||||
|
|
||||||
getOrderItemNeighborsRouteHandler
|
|
||||||
} from '../../services/inventory/orderitems.js';
|
} from '../../services/inventory/orderitems.js';
|
||||||
|
|
||||||
// list of order items
|
// list of order items
|
||||||
router.get('/', isAuthenticated, (req, res) => {
|
router.get('/', isAuthenticated, (req, res) => {
|
||||||
const { page, limit, property, search, sort, order } = req.query;
|
const { page, limit, property, search, sort, order } = req.query;
|
||||||
const filter = getFilter(req.query, listAllowedFilters);
|
const allowedFilters = ['itemType', 'item', 'item._id', 'order', 'order._id', 'orderType'];
|
||||||
|
const filter = getFilter(req.query, allowedFilters);
|
||||||
listOrderItemsRouteHandler(req, res, page, limit, property, filter, search, sort, order);
|
listOrderItemsRouteHandler(req, res, page, limit, property, filter, search, sort, order);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/properties', isAuthenticated, (req, res) => {
|
router.get('/properties', isAuthenticated, (req, res) => {
|
||||||
let properties = convertPropertiesString(req.query.properties);
|
let properties = convertPropertiesString(req.query.properties);
|
||||||
const filter = getFilter(req.query, propertiesAllowedFilters, false);
|
const allowedFilters = ['itemType', 'item', 'item._id', 'order', 'order._id', 'orderType'];
|
||||||
|
const filter = getFilter(req.query, allowedFilters, false);
|
||||||
var masterFilter = {};
|
var masterFilter = {};
|
||||||
if (req.query.masterFilter) {
|
if (req.query.masterFilter) {
|
||||||
masterFilter = JSON.parse(req.query.masterFilter);
|
masterFilter = JSON.parse(req.query.masterFilter);
|
||||||
@ -63,16 +33,6 @@ router.get('/properties', isAuthenticated, (req, res) => {
|
|||||||
listOrderItemsByPropertiesRouteHandler(req, res, properties, filter, masterFilter);
|
listOrderItemsByPropertiesRouteHandler(req, res, properties, filter, masterFilter);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/values', isAuthenticated, (req, res) => {
|
|
||||||
const { property } = req.query;
|
|
||||||
getOrderItemPropertyValuesRouteHandler(req, res, property);
|
|
||||||
});
|
|
||||||
router.get('/search', isAuthenticated, (req, res) => {
|
|
||||||
const { search } = req.query;
|
|
||||||
searchOrderItemsRouteHandler(req, res, search);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
router.post('/', isAuthenticated, (req, res) => {
|
router.post('/', isAuthenticated, (req, res) => {
|
||||||
newOrderItemRouteHandler(req, res);
|
newOrderItemRouteHandler(req, res);
|
||||||
});
|
});
|
||||||
@ -87,21 +47,10 @@ router.get('/history', isAuthenticated, (req, res) => {
|
|||||||
getOrderItemHistoryRouteHandler(req, res);
|
getOrderItemHistoryRouteHandler(req, res);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/neighbors', isAuthenticated, (req, res) => {
|
|
||||||
const { property, search, sort, order, id } = req.query;
|
|
||||||
const filter = getFilter(req.query, listAllowedFilters);
|
|
||||||
getOrderItemNeighborsRouteHandler(req, res, property, filter, search, sort, order, id);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get('/:id', isAuthenticated, (req, res) => {
|
router.get('/:id', isAuthenticated, (req, res) => {
|
||||||
getOrderItemRouteHandler(req, res);
|
getOrderItemRouteHandler(req, res);
|
||||||
});
|
});
|
||||||
|
|
||||||
// update multiple order items
|
|
||||||
router.put('/', isAuthenticated, async (req, res) => {
|
|
||||||
editMultipleOrderItemsRouteHandler(req, res);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.put('/:id', isAuthenticated, async (req, res) => {
|
router.put('/:id', isAuthenticated, async (req, res) => {
|
||||||
editOrderItemRouteHandler(req, res);
|
editOrderItemRouteHandler(req, res);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -3,46 +3,29 @@ import { isAuthenticated } from '../../keycloak.js';
|
|||||||
import { getFilter, convertPropertiesString } from '../../utils.js';
|
import { getFilter, convertPropertiesString } from '../../utils.js';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
const listAllowedFilters = [
|
|
||||||
'partSku',
|
|
||||||
'state',
|
|
||||||
'startingQuantity',
|
|
||||||
'currentQuantity',
|
|
||||||
'partSku._id',
|
|
||||||
'stockLocation',
|
|
||||||
'stockLocation._id',
|
|
||||||
'createdAt',
|
|
||||||
'updatedAt',
|
|
||||||
'_reference'
|
|
||||||
];
|
|
||||||
const propertiesAllowedFilters = ['part', 'state.type'];
|
|
||||||
import {
|
import {
|
||||||
listPartStocksRouteHandler,
|
listPartStocksRouteHandler,
|
||||||
getPartStockRouteHandler,
|
getPartStockRouteHandler,
|
||||||
editPartStockRouteHandler,
|
editPartStockRouteHandler,
|
||||||
editMultiplePartStocksRouteHandler,
|
|
||||||
newPartStockRouteHandler,
|
newPartStockRouteHandler,
|
||||||
deletePartStockRouteHandler,
|
deletePartStockRouteHandler,
|
||||||
listPartStocksByPropertiesRouteHandler,
|
listPartStocksByPropertiesRouteHandler,
|
||||||
getPartStockStatsRouteHandler,
|
getPartStockStatsRouteHandler,
|
||||||
getPartStockHistoryRouteHandler,
|
getPartStockHistoryRouteHandler,
|
||||||
searchPartStocksRouteHandler,
|
|
||||||
getPartStockPropertyValuesRouteHandler,
|
|
||||||
|
|
||||||
getPartStockNeighborsRouteHandler
|
|
||||||
} from '../../services/inventory/partstocks.js';
|
} from '../../services/inventory/partstocks.js';
|
||||||
|
|
||||||
// list of part stocks
|
// list of part stocks
|
||||||
router.get('/', isAuthenticated, (req, res) => {
|
router.get('/', isAuthenticated, (req, res) => {
|
||||||
const { page, limit, property, search, sort, order } = req.query;
|
const { page, limit, property, search, sort, order } = req.query;
|
||||||
const filter = getFilter(req.query, listAllowedFilters);
|
const allowedFilters = ['part', 'state', 'startingQuantity', 'currentQuantity', 'part._id'];
|
||||||
|
const filter = getFilter(req.query, allowedFilters);
|
||||||
listPartStocksRouteHandler(req, res, page, limit, property, filter, search, sort, order);
|
listPartStocksRouteHandler(req, res, page, limit, property, filter, search, sort, order);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/properties', isAuthenticated, (req, res) => {
|
router.get('/properties', isAuthenticated, (req, res) => {
|
||||||
let properties = convertPropertiesString(req.query.properties);
|
let properties = convertPropertiesString(req.query.properties);
|
||||||
const filter = getFilter(req.query, propertiesAllowedFilters, false);
|
const allowedFilters = ['part', 'state.type'];
|
||||||
|
const filter = getFilter(req.query, allowedFilters, false);
|
||||||
var masterFilter = {};
|
var masterFilter = {};
|
||||||
if (req.query.masterFilter) {
|
if (req.query.masterFilter) {
|
||||||
masterFilter = JSON.parse(req.query.masterFilter);
|
masterFilter = JSON.parse(req.query.masterFilter);
|
||||||
@ -50,16 +33,6 @@ router.get('/properties', isAuthenticated, (req, res) => {
|
|||||||
listPartStocksByPropertiesRouteHandler(req, res, properties, filter, masterFilter);
|
listPartStocksByPropertiesRouteHandler(req, res, properties, filter, masterFilter);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/values', isAuthenticated, (req, res) => {
|
|
||||||
const { property } = req.query;
|
|
||||||
getPartStockPropertyValuesRouteHandler(req, res, property);
|
|
||||||
});
|
|
||||||
router.get('/search', isAuthenticated, (req, res) => {
|
|
||||||
const { search } = req.query;
|
|
||||||
searchPartStocksRouteHandler(req, res, search);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
router.post('/', isAuthenticated, (req, res) => {
|
router.post('/', isAuthenticated, (req, res) => {
|
||||||
newPartStockRouteHandler(req, res);
|
newPartStockRouteHandler(req, res);
|
||||||
});
|
});
|
||||||
@ -74,21 +47,10 @@ router.get('/history', isAuthenticated, (req, res) => {
|
|||||||
getPartStockHistoryRouteHandler(req, res);
|
getPartStockHistoryRouteHandler(req, res);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/neighbors', isAuthenticated, (req, res) => {
|
|
||||||
const { property, search, sort, order, id } = req.query;
|
|
||||||
const filter = getFilter(req.query, listAllowedFilters);
|
|
||||||
getPartStockNeighborsRouteHandler(req, res, property, filter, search, sort, order, id);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get('/:id', isAuthenticated, (req, res) => {
|
router.get('/:id', isAuthenticated, (req, res) => {
|
||||||
getPartStockRouteHandler(req, res);
|
getPartStockRouteHandler(req, res);
|
||||||
});
|
});
|
||||||
|
|
||||||
// update multiple part stocks
|
|
||||||
router.put('/', isAuthenticated, async (req, res) => {
|
|
||||||
editMultiplePartStocksRouteHandler(req, res);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.put('/:id', isAuthenticated, async (req, res) => {
|
router.put('/:id', isAuthenticated, async (req, res) => {
|
||||||
editPartStockRouteHandler(req, res);
|
editPartStockRouteHandler(req, res);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,100 +0,0 @@
|
|||||||
import express from 'express';
|
|
||||||
import { isAuthenticated } from '../../keycloak.js';
|
|
||||||
import { getFilter, convertPropertiesString } from '../../utils.js';
|
|
||||||
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
const listAllowedFilters = [
|
|
||||||
'productSku',
|
|
||||||
'state',
|
|
||||||
'currentQuantity',
|
|
||||||
'productSku._id',
|
|
||||||
'stockLocation',
|
|
||||||
'stockLocation._id',
|
|
||||||
'createdAt',
|
|
||||||
'updatedAt',
|
|
||||||
'_reference'
|
|
||||||
];
|
|
||||||
const propertiesAllowedFilters = ['productSku', 'state.type'];
|
|
||||||
import {
|
|
||||||
listProductStocksRouteHandler,
|
|
||||||
getProductStockRouteHandler,
|
|
||||||
editProductStockRouteHandler,
|
|
||||||
editMultipleProductStocksRouteHandler,
|
|
||||||
newProductStockRouteHandler,
|
|
||||||
deleteProductStockRouteHandler,
|
|
||||||
postProductStockRouteHandler,
|
|
||||||
listProductStocksByPropertiesRouteHandler,
|
|
||||||
getProductStockStatsRouteHandler,
|
|
||||||
getProductStockHistoryRouteHandler,
|
|
||||||
searchProductStocksRouteHandler,
|
|
||||||
getProductStockPropertyValuesRouteHandler,
|
|
||||||
|
|
||||||
getProductStockNeighborsRouteHandler
|
|
||||||
} from '../../services/inventory/productstocks.js';
|
|
||||||
|
|
||||||
router.get('/', isAuthenticated, (req, res) => {
|
|
||||||
const { page, limit, property, search, sort, order } = req.query;
|
|
||||||
const filter = getFilter(req.query, listAllowedFilters);
|
|
||||||
listProductStocksRouteHandler(req, res, page, limit, property, filter, search, sort, order);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get('/properties', isAuthenticated, (req, res) => {
|
|
||||||
let properties = convertPropertiesString(req.query.properties);
|
|
||||||
const filter = getFilter(req.query, propertiesAllowedFilters, false);
|
|
||||||
var masterFilter = {};
|
|
||||||
if (req.query.masterFilter) {
|
|
||||||
masterFilter = JSON.parse(req.query.masterFilter);
|
|
||||||
}
|
|
||||||
listProductStocksByPropertiesRouteHandler(req, res, properties, filter, masterFilter);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get('/values', isAuthenticated, (req, res) => {
|
|
||||||
const { property } = req.query;
|
|
||||||
getProductStockPropertyValuesRouteHandler(req, res, property);
|
|
||||||
});
|
|
||||||
router.get('/search', isAuthenticated, (req, res) => {
|
|
||||||
const { search } = req.query;
|
|
||||||
searchProductStocksRouteHandler(req, res, search);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
router.post('/', isAuthenticated, (req, res) => {
|
|
||||||
newProductStockRouteHandler(req, res);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get('/stats', isAuthenticated, (req, res) => {
|
|
||||||
getProductStockStatsRouteHandler(req, res);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get('/history', isAuthenticated, (req, res) => {
|
|
||||||
getProductStockHistoryRouteHandler(req, res);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get('/neighbors', isAuthenticated, (req, res) => {
|
|
||||||
const { property, search, sort, order, id } = req.query;
|
|
||||||
const filter = getFilter(req.query, listAllowedFilters);
|
|
||||||
getProductStockNeighborsRouteHandler(req, res, property, filter, search, sort, order, id);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get('/:id', isAuthenticated, (req, res) => {
|
|
||||||
getProductStockRouteHandler(req, res);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.put('/', isAuthenticated, async (req, res) => {
|
|
||||||
editMultipleProductStocksRouteHandler(req, res);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.put('/:id', isAuthenticated, async (req, res) => {
|
|
||||||
editProductStockRouteHandler(req, res);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.delete('/:id', isAuthenticated, async (req, res) => {
|
|
||||||
deleteProductStockRouteHandler(req, res);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post('/:id/post', isAuthenticated, async (req, res) => {
|
|
||||||
postProductStockRouteHandler(req, res);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
@ -3,78 +3,36 @@ import { isAuthenticated } from '../../keycloak.js';
|
|||||||
import { getFilter, convertPropertiesString } from '../../utils.js';
|
import { getFilter, convertPropertiesString } from '../../utils.js';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
const listAllowedFilters = [
|
|
||||||
'vendor',
|
|
||||||
'state',
|
|
||||||
'value',
|
|
||||||
'vendor._id',
|
|
||||||
'totalAmount',
|
|
||||||
'totalAmountWithTax',
|
|
||||||
'totalTaxAmount',
|
|
||||||
'shippingAmount',
|
|
||||||
'shippingAmountWithTax',
|
|
||||||
'grandTotalAmount',
|
|
||||||
'createdAt',
|
|
||||||
'updatedAt',
|
|
||||||
'_reference',
|
|
||||||
];
|
|
||||||
const propertiesAllowedFilters = [
|
|
||||||
'vendor',
|
|
||||||
'state.type',
|
|
||||||
'value',
|
|
||||||
'vendor._id',
|
|
||||||
'totalAmount',
|
|
||||||
'totalAmountWithTax',
|
|
||||||
'totalTaxAmount',
|
|
||||||
'shippingAmount',
|
|
||||||
'shippingAmountWithTax',
|
|
||||||
'grandTotalAmount',
|
|
||||||
];
|
|
||||||
import {
|
import {
|
||||||
listPurchaseOrdersRouteHandler,
|
listPurchaseOrdersRouteHandler,
|
||||||
getPurchaseOrderRouteHandler,
|
getPurchaseOrderRouteHandler,
|
||||||
editPurchaseOrderRouteHandler,
|
editPurchaseOrderRouteHandler,
|
||||||
editMultiplePurchaseOrdersRouteHandler,
|
|
||||||
newPurchaseOrderRouteHandler,
|
newPurchaseOrderRouteHandler,
|
||||||
deletePurchaseOrderRouteHandler,
|
deletePurchaseOrderRouteHandler,
|
||||||
listPurchaseOrdersByPropertiesRouteHandler,
|
listPurchaseOrdersByPropertiesRouteHandler,
|
||||||
getPurchaseOrderStatsRouteHandler,
|
getPurchaseOrderStatsRouteHandler,
|
||||||
getPurchaseOrderHistoryRouteHandler,
|
getPurchaseOrderHistoryRouteHandler,
|
||||||
postPurchaseOrderRouteHandler,
|
|
||||||
acknowledgePurchaseOrderRouteHandler,
|
|
||||||
cancelPurchaseOrderRouteHandler,
|
|
||||||
searchPurchaseOrdersRouteHandler,
|
|
||||||
getPurchaseOrderPropertyValuesRouteHandler,
|
|
||||||
getPurchaseOrderNeighborsRouteHandler,
|
|
||||||
} from '../../services/inventory/purchaseorders.js';
|
} from '../../services/inventory/purchaseorders.js';
|
||||||
|
|
||||||
// list of purchase orders
|
// list of purchase orders
|
||||||
router.get('/', isAuthenticated, (req, res) => {
|
router.get('/', isAuthenticated, (req, res) => {
|
||||||
const { page, limit, property, search, sort, order } = req.query;
|
const { page, limit, property, search, sort, order } = req.query;
|
||||||
const filter = getFilter(req.query, listAllowedFilters);
|
const allowedFilters = ['vendor', 'state', 'value', 'vendor._id'];
|
||||||
|
const filter = getFilter(req.query, allowedFilters);
|
||||||
listPurchaseOrdersRouteHandler(req, res, page, limit, property, filter, search, sort, order);
|
listPurchaseOrdersRouteHandler(req, res, page, limit, property, filter, search, sort, order);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/properties', isAuthenticated, (req, res) => {
|
router.get('/properties', isAuthenticated, (req, res) => {
|
||||||
let properties = convertPropertiesString(req.query.properties);
|
let properties = convertPropertiesString(req.query.properties);
|
||||||
const filter = getFilter(req.query, propertiesAllowedFilters, false);
|
const allowedFilters = ['vendor', 'state.type', 'value', 'vendor._id'];
|
||||||
|
const filter = getFilter(req.query, allowedFilters, false);
|
||||||
var masterFilter = {};
|
var masterFilter = {};
|
||||||
if (req.query.masterFilter) {
|
if (req.query.masterFilter) {
|
||||||
masterFilter = getFilter(JSON.parse(req.query.masterFilter), propertiesAllowedFilters, true);
|
masterFilter = JSON.parse(req.query.masterFilter);
|
||||||
}
|
}
|
||||||
listPurchaseOrdersByPropertiesRouteHandler(req, res, properties, filter, masterFilter);
|
listPurchaseOrdersByPropertiesRouteHandler(req, res, properties, filter, masterFilter);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/values', isAuthenticated, (req, res) => {
|
|
||||||
const { property } = req.query;
|
|
||||||
getPurchaseOrderPropertyValuesRouteHandler(req, res, property);
|
|
||||||
});
|
|
||||||
router.get('/search', isAuthenticated, (req, res) => {
|
|
||||||
const { search } = req.query;
|
|
||||||
searchPurchaseOrdersRouteHandler(req, res, search);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post('/', isAuthenticated, (req, res) => {
|
router.post('/', isAuthenticated, (req, res) => {
|
||||||
newPurchaseOrderRouteHandler(req, res);
|
newPurchaseOrderRouteHandler(req, res);
|
||||||
});
|
});
|
||||||
@ -89,21 +47,10 @@ router.get('/history', isAuthenticated, (req, res) => {
|
|||||||
getPurchaseOrderHistoryRouteHandler(req, res);
|
getPurchaseOrderHistoryRouteHandler(req, res);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/neighbors', isAuthenticated, (req, res) => {
|
|
||||||
const { property, search, sort, order, id } = req.query;
|
|
||||||
const filter = getFilter(req.query, listAllowedFilters);
|
|
||||||
getPurchaseOrderNeighborsRouteHandler(req, res, property, filter, search, sort, order, id);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get('/:id', isAuthenticated, (req, res) => {
|
router.get('/:id', isAuthenticated, (req, res) => {
|
||||||
getPurchaseOrderRouteHandler(req, res);
|
getPurchaseOrderRouteHandler(req, res);
|
||||||
});
|
});
|
||||||
|
|
||||||
// update multiple purchase orders
|
|
||||||
router.put('/', isAuthenticated, async (req, res) => {
|
|
||||||
editMultiplePurchaseOrdersRouteHandler(req, res);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.put('/:id', isAuthenticated, async (req, res) => {
|
router.put('/:id', isAuthenticated, async (req, res) => {
|
||||||
editPurchaseOrderRouteHandler(req, res);
|
editPurchaseOrderRouteHandler(req, res);
|
||||||
});
|
});
|
||||||
@ -112,16 +59,4 @@ router.delete('/:id', isAuthenticated, async (req, res) => {
|
|||||||
deletePurchaseOrderRouteHandler(req, res);
|
deletePurchaseOrderRouteHandler(req, res);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/:id/post', isAuthenticated, async (req, res) => {
|
|
||||||
postPurchaseOrderRouteHandler(req, res);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post('/:id/acknowledge', isAuthenticated, async (req, res) => {
|
|
||||||
acknowledgePurchaseOrderRouteHandler(req, res);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post('/:id/cancel', isAuthenticated, async (req, res) => {
|
|
||||||
cancelPurchaseOrderRouteHandler(req, res);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@ -3,70 +3,50 @@ import { isAuthenticated } from '../../keycloak.js';
|
|||||||
import { getFilter, convertPropertiesString } from '../../utils.js';
|
import { getFilter, convertPropertiesString } from '../../utils.js';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
const listAllowedFilters = [
|
|
||||||
'orderType',
|
|
||||||
'order',
|
|
||||||
'state',
|
|
||||||
'courierService',
|
|
||||||
'order._id',
|
|
||||||
'taxRate',
|
|
||||||
'createdAt',
|
|
||||||
'updatedAt',
|
|
||||||
'_reference',
|
|
||||||
];
|
|
||||||
const propertiesAllowedFilters = [
|
|
||||||
'orderType',
|
|
||||||
'order',
|
|
||||||
'state.type',
|
|
||||||
'courierService',
|
|
||||||
'order._id',
|
|
||||||
'taxRate',
|
|
||||||
];
|
|
||||||
import {
|
import {
|
||||||
listShipmentsRouteHandler,
|
listShipmentsRouteHandler,
|
||||||
getShipmentRouteHandler,
|
getShipmentRouteHandler,
|
||||||
editShipmentRouteHandler,
|
editShipmentRouteHandler,
|
||||||
editMultipleShipmentsRouteHandler,
|
|
||||||
newShipmentRouteHandler,
|
newShipmentRouteHandler,
|
||||||
deleteShipmentRouteHandler,
|
deleteShipmentRouteHandler,
|
||||||
listShipmentsByPropertiesRouteHandler,
|
listShipmentsByPropertiesRouteHandler,
|
||||||
getShipmentStatsRouteHandler,
|
getShipmentStatsRouteHandler,
|
||||||
getShipmentHistoryRouteHandler,
|
getShipmentHistoryRouteHandler,
|
||||||
shipShipmentRouteHandler,
|
|
||||||
receiveShipmentRouteHandler,
|
|
||||||
cancelShipmentRouteHandler,
|
|
||||||
searchShipmentsRouteHandler,
|
|
||||||
getShipmentPropertyValuesRouteHandler,
|
|
||||||
getShipmentNeighborsRouteHandler,
|
|
||||||
} from '../../services/inventory/shipments.js';
|
} from '../../services/inventory/shipments.js';
|
||||||
|
|
||||||
// list of shipments
|
// list of shipments
|
||||||
router.get('/', isAuthenticated, (req, res) => {
|
router.get('/', isAuthenticated, (req, res) => {
|
||||||
const { page, limit, property, search, sort, order } = req.query;
|
const { page, limit, property, search, sort, order } = req.query;
|
||||||
const filter = getFilter(req.query, listAllowedFilters);
|
const allowedFilters = [
|
||||||
|
'vendor',
|
||||||
|
'purchaseOrder',
|
||||||
|
'state',
|
||||||
|
'courierService',
|
||||||
|
'vendor._id',
|
||||||
|
'purchaseOrder._id',
|
||||||
|
];
|
||||||
|
const filter = getFilter(req.query, allowedFilters);
|
||||||
listShipmentsRouteHandler(req, res, page, limit, property, filter, search, sort, order);
|
listShipmentsRouteHandler(req, res, page, limit, property, filter, search, sort, order);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/properties', isAuthenticated, (req, res) => {
|
router.get('/properties', isAuthenticated, (req, res) => {
|
||||||
let properties = convertPropertiesString(req.query.properties);
|
let properties = convertPropertiesString(req.query.properties);
|
||||||
const filter = getFilter(req.query, propertiesAllowedFilters, false);
|
const allowedFilters = [
|
||||||
|
'vendor',
|
||||||
|
'purchaseOrder',
|
||||||
|
'state.type',
|
||||||
|
'courierService',
|
||||||
|
'vendor._id',
|
||||||
|
'purchaseOrder._id',
|
||||||
|
];
|
||||||
|
const filter = getFilter(req.query, allowedFilters, false);
|
||||||
var masterFilter = {};
|
var masterFilter = {};
|
||||||
if (req.query.masterFilter) {
|
if (req.query.masterFilter) {
|
||||||
masterFilter = getFilter(JSON.parse(req.query.masterFilter), propertiesAllowedFilters, true);
|
masterFilter = JSON.parse(req.query.masterFilter);
|
||||||
}
|
}
|
||||||
listShipmentsByPropertiesRouteHandler(req, res, properties, filter, masterFilter);
|
listShipmentsByPropertiesRouteHandler(req, res, properties, filter, masterFilter);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/values', isAuthenticated, (req, res) => {
|
|
||||||
const { property } = req.query;
|
|
||||||
getShipmentPropertyValuesRouteHandler(req, res, property);
|
|
||||||
});
|
|
||||||
router.get('/search', isAuthenticated, (req, res) => {
|
|
||||||
const { search } = req.query;
|
|
||||||
searchShipmentsRouteHandler(req, res, search);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post('/', isAuthenticated, (req, res) => {
|
router.post('/', isAuthenticated, (req, res) => {
|
||||||
newShipmentRouteHandler(req, res);
|
newShipmentRouteHandler(req, res);
|
||||||
});
|
});
|
||||||
@ -81,21 +61,10 @@ router.get('/history', isAuthenticated, (req, res) => {
|
|||||||
getShipmentHistoryRouteHandler(req, res);
|
getShipmentHistoryRouteHandler(req, res);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/neighbors', isAuthenticated, (req, res) => {
|
|
||||||
const { property, search, sort, order, id } = req.query;
|
|
||||||
const filter = getFilter(req.query, listAllowedFilters);
|
|
||||||
getShipmentNeighborsRouteHandler(req, res, property, filter, search, sort, order, id);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get('/:id', isAuthenticated, (req, res) => {
|
router.get('/:id', isAuthenticated, (req, res) => {
|
||||||
getShipmentRouteHandler(req, res);
|
getShipmentRouteHandler(req, res);
|
||||||
});
|
});
|
||||||
|
|
||||||
// update multiple shipments
|
|
||||||
router.put('/', isAuthenticated, async (req, res) => {
|
|
||||||
editMultipleShipmentsRouteHandler(req, res);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.put('/:id', isAuthenticated, async (req, res) => {
|
router.put('/:id', isAuthenticated, async (req, res) => {
|
||||||
editShipmentRouteHandler(req, res);
|
editShipmentRouteHandler(req, res);
|
||||||
});
|
});
|
||||||
@ -104,16 +73,4 @@ router.delete('/:id', isAuthenticated, async (req, res) => {
|
|||||||
deleteShipmentRouteHandler(req, res);
|
deleteShipmentRouteHandler(req, res);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/:id/ship', isAuthenticated, async (req, res) => {
|
|
||||||
shipShipmentRouteHandler(req, res);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post('/:id/receive', isAuthenticated, async (req, res) => {
|
|
||||||
receiveShipmentRouteHandler(req, res);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post('/:id/cancel', isAuthenticated, async (req, res) => {
|
|
||||||
cancelShipmentRouteHandler(req, res);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { isAuthenticated } from '../../keycloak.js';
|
import { isAuthenticated } from '../../keycloak.js';
|
||||||
import { parseFilter, getFilter } from '../../utils.js';
|
import { parseFilter } from '../../utils.js';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
import {
|
import {
|
||||||
@ -11,29 +11,19 @@ import {
|
|||||||
deleteStockAuditRouteHandler,
|
deleteStockAuditRouteHandler,
|
||||||
getStockAuditStatsRouteHandler,
|
getStockAuditStatsRouteHandler,
|
||||||
getStockAuditHistoryRouteHandler,
|
getStockAuditHistoryRouteHandler,
|
||||||
searchStockAuditsRouteHandler,
|
|
||||||
getStockAuditNeighborsRouteHandler,
|
|
||||||
} from '../../services/inventory/stockaudits.js';
|
} from '../../services/inventory/stockaudits.js';
|
||||||
|
|
||||||
const listAllowedFilters = [
|
|
||||||
'status',
|
|
||||||
'type',
|
|
||||||
'createdBy',
|
|
||||||
'state',
|
|
||||||
'createdAt',
|
|
||||||
'updatedAt',
|
|
||||||
'_reference'
|
|
||||||
];
|
|
||||||
|
|
||||||
// List stock audits
|
// List stock audits
|
||||||
router.get('/', isAuthenticated, (req, res) => {
|
router.get('/', isAuthenticated, (req, res) => {
|
||||||
const { page, limit, property } = req.query;
|
const { page, limit, property } = req.query;
|
||||||
|
|
||||||
|
const allowedFilters = ['status', 'type', 'createdBy'];
|
||||||
|
|
||||||
var filter = {};
|
var filter = {};
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(req.query)) {
|
for (const [key, value] of Object.entries(req.query)) {
|
||||||
for (var i = 0; i < listAllowedFilters.length; i++) {
|
for (var i = 0; i < allowedFilters.length; i++) {
|
||||||
if (key == listAllowedFilters[i]) {
|
if (key == allowedFilters[i]) {
|
||||||
const parsedFilter = parseFilter(key, value);
|
const parsedFilter = parseFilter(key, value);
|
||||||
filter = { ...filter, ...parsedFilter };
|
filter = { ...filter, ...parsedFilter };
|
||||||
}
|
}
|
||||||
@ -49,11 +39,6 @@ router.post('/', isAuthenticated, (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// get stock audit stats
|
// get stock audit stats
|
||||||
router.get('/search', isAuthenticated, (req, res) => {
|
|
||||||
const { search } = req.query;
|
|
||||||
searchStockAuditsRouteHandler(req, res, search);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get('/stats', isAuthenticated, (req, res) => {
|
router.get('/stats', isAuthenticated, (req, res) => {
|
||||||
getStockAuditStatsRouteHandler(req, res);
|
getStockAuditStatsRouteHandler(req, res);
|
||||||
});
|
});
|
||||||
@ -63,12 +48,6 @@ router.get('/history', isAuthenticated, (req, res) => {
|
|||||||
getStockAuditHistoryRouteHandler(req, res);
|
getStockAuditHistoryRouteHandler(req, res);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/neighbors', isAuthenticated, (req, res) => {
|
|
||||||
const { property, search, sort, order, id } = req.query;
|
|
||||||
const filter = getFilter(req.query, listAllowedFilters);
|
|
||||||
getStockAuditNeighborsRouteHandler(req, res, property, filter, search, sort, order, id);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get specific stock audit
|
// Get specific stock audit
|
||||||
router.get('/:id', isAuthenticated, (req, res) => {
|
router.get('/:id', isAuthenticated, (req, res) => {
|
||||||
getStockAuditRouteHandler(req, res);
|
getStockAuditRouteHandler(req, res);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user