Initial Commit
Some checks failed
farmcontrol/farmcontrol-scheduler/pipeline/head There was a failure building this commit
Some checks failed
farmcontrol/farmcontrol-scheduler/pipeline/head There was a failure building this commit
This commit is contained in:
commit
f9284ed1f2
13
.eslintignore
Normal file
13
.eslintignore
Normal file
@ -0,0 +1,13 @@
|
||||
node_modules/
|
||||
dist/
|
||||
build/
|
||||
coverage/
|
||||
*.min.js
|
||||
*.bundle.js
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
pnpm-lock.yaml
|
||||
.env
|
||||
.env.*
|
||||
logs/
|
||||
*.log
|
||||
100
.eslintrc.json
Normal file
100
.eslintrc.json
Normal file
@ -0,0 +1,100 @@
|
||||
{
|
||||
"env": {
|
||||
"node": true,
|
||||
"es2022": true
|
||||
},
|
||||
"extends": ["eslint:recommended", "prettier"],
|
||||
"plugins": ["prettier"],
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 2022,
|
||||
"sourceType": "module"
|
||||
},
|
||||
"rules": {
|
||||
"prettier/prettier": "error",
|
||||
"indent": ["error", 2],
|
||||
"linebreak-style": ["error", "unix"],
|
||||
"quotes": ["error", "single"],
|
||||
"semi": ["error", "always"],
|
||||
"no-unused-vars": ["error", { "argsIgnorePattern": "^_" }],
|
||||
"no-console": "warn",
|
||||
"prefer-const": "error",
|
||||
"no-var": "error",
|
||||
"object-shorthand": "error",
|
||||
"prefer-template": "error",
|
||||
"template-curly-spacing": ["error", "never"],
|
||||
"arrow-spacing": "error",
|
||||
"no-duplicate-imports": "error",
|
||||
"no-useless-rename": "error",
|
||||
"prefer-destructuring": [
|
||||
"error",
|
||||
{
|
||||
"array": true,
|
||||
"object": true
|
||||
}
|
||||
],
|
||||
"prefer-rest-params": "error",
|
||||
"prefer-spread": "error",
|
||||
"no-useless-constructor": "error",
|
||||
"no-useless-computed-key": "error",
|
||||
"no-useless-escape": "error",
|
||||
"no-useless-return": "error",
|
||||
"no-constant-condition": ["error", { "checkLoops": false }],
|
||||
"no-empty": ["error", { "allowEmptyCatch": true }],
|
||||
"no-extra-boolean-cast": "error",
|
||||
"no-extra-semi": "error",
|
||||
"no-irregular-whitespace": "error",
|
||||
"no-multiple-empty-lines": ["error", { "max": 2 }],
|
||||
"no-trailing-spaces": "error",
|
||||
"eol-last": "error",
|
||||
"comma-dangle": ["error", "never"],
|
||||
"comma-spacing": ["error", { "before": false, "after": true }],
|
||||
"comma-style": ["error", "last"],
|
||||
"key-spacing": ["error", { "beforeColon": false, "afterColon": true }],
|
||||
"keyword-spacing": ["error", { "before": true, "after": true }],
|
||||
"object-curly-spacing": ["error", "always"],
|
||||
"array-bracket-spacing": ["error", "never"],
|
||||
"space-before-blocks": "error",
|
||||
"space-before-function-paren": [
|
||||
"error",
|
||||
{
|
||||
"anonymous": "always",
|
||||
"named": "never",
|
||||
"asyncArrow": "always"
|
||||
}
|
||||
],
|
||||
"space-in-parens": ["error", "never"],
|
||||
"space-infix-ops": "error",
|
||||
"space-unary-ops": [
|
||||
"error",
|
||||
{
|
||||
"words": true,
|
||||
"nonwords": false
|
||||
}
|
||||
],
|
||||
"spaced-comment": ["error", "always"],
|
||||
"brace-style": ["error", "1tbs", { "allowSingleLine": true }],
|
||||
"camelcase": ["error", { "properties": "never" }],
|
||||
"new-cap": ["error", { "newIsCap": true, "capIsNew": false }],
|
||||
"new-parens": "error",
|
||||
"no-array-constructor": "error",
|
||||
"no-new-object": "error",
|
||||
"no-new-require": "error",
|
||||
"no-path-concat": "error",
|
||||
"no-process-exit": "error",
|
||||
"no-return-assign": "error",
|
||||
"no-self-compare": "error",
|
||||
"no-sequences": "error",
|
||||
"no-throw-literal": "error",
|
||||
"no-unmodified-loop-condition": "error",
|
||||
"no-unused-expressions": "error",
|
||||
"no-useless-call": "error",
|
||||
"no-useless-concat": "error",
|
||||
"no-useless-escape": "error",
|
||||
"no-void": "error",
|
||||
"no-warning-comments": "warn",
|
||||
"prefer-promise-reject-errors": "error",
|
||||
"require-await": "error",
|
||||
"yoda": "error"
|
||||
},
|
||||
"ignorePatterns": ["node_modules/", "dist/", "build/", "*.min.js"]
|
||||
}
|
||||
144
.gitignore
vendored
Normal file
144
.gitignore
vendored
Normal file
@ -0,0 +1,144 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
*.DS_STORE
|
||||
|
||||
*.env
|
||||
|
||||
test-results.xml
|
||||
|
||||
# Jenkins generated build metadata
|
||||
src/buildInfo.json
|
||||
|
||||
DS_STORE
|
||||
**/DS_Store
|
||||
|
||||
test-results.xml
|
||||
7
.prettierignore
Normal file
7
.prettierignore
Normal file
@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
dist/
|
||||
build/
|
||||
*.min.js
|
||||
package-lock.json
|
||||
.git/
|
||||
.vscode/
|
||||
104
Jenkinsfile
vendored
Normal file
104
Jenkinsfile
vendored
Normal file
@ -0,0 +1,104 @@
|
||||
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
|
||||
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: 'node_modules/**',
|
||||
execCommand: '''
|
||||
cd /home/farmcontrol/farmcontrol-scheduler
|
||||
pnpm install --production
|
||||
sudo systemctl restart farmcontrol-scheduler
|
||||
''',
|
||||
execTimeout: 120000,
|
||||
flatten: false,
|
||||
makeEmptyDirs: false,
|
||||
noDefaultExcludes: false,
|
||||
patternSeparator: '[, ]+',
|
||||
remoteDirectory: 'farmcontrol-scheduler',
|
||||
remoteDirectorySDF: false,
|
||||
removePrefix: '',
|
||||
sourceFiles: '**/*'
|
||||
)
|
||||
],
|
||||
usePromotionTimestamp: false,
|
||||
useWorkspaceInPromotion: false,
|
||||
verbose: true
|
||||
)
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
post {
|
||||
always {
|
||||
cleanWs()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
83
README.md
Normal file
83
README.md
Normal file
@ -0,0 +1,83 @@
|
||||
# Farm Control Scheduler
|
||||
|
||||
[](https://ci.tombutcher.work/job/farmcontrol/job/farmcontrol-scheduler/job/main/)
|
||||
|
||||
A background service for Farm Control ERP that fires time-sensitive events on model objects once their scheduled date/time has passed.
|
||||
|
||||
## Features
|
||||
|
||||
- Scans configured models for scheduled properties (e.g. a "publish at" or "expires at" date) and picks up any that are now due.
|
||||
- Runs the scheduling loop on a dedicated worker thread, so checks never block the main application event loop.
|
||||
- Calls `onSchedulerEvent(property, value)` on the relevant model instance once its scheduled time arrives.
|
||||
|
||||
## How it works
|
||||
|
||||
1. On startup, `SchedulerManager` inspects every registered model for a `scheduledProperties` definition and queries the database for any matching objects that are due (or already overdue).
|
||||
2. Each match is wrapped in a `Scheduler` (`id`, `objectType`, `object`, `property`, `value`) and added to the manager's schedulers list.
|
||||
3. A worker thread (`schedulerWorker.js`) polls the list on an interval, comparing each scheduler's `value` against the current date/time.
|
||||
4. When a scheduler is due, the worker notifies the main thread, which calls `object.onSchedulerEvent(property, value)` and removes the scheduler from the list.
|
||||
|
||||
Models opt in to this behaviour by defining `scheduledProperties` on their Mongoose schema and implementing an `onSchedulerEvent(property, value)` method to handle the callback.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js (v16 or higher)
|
||||
- MongoDB server
|
||||
- Redis server
|
||||
|
||||
## Installation
|
||||
|
||||
1. Clone the repository
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
The application uses `config.json` for configuration. At minimum it expects a `server` block controlling logging, plus connection details for MongoDB and Redis:
|
||||
|
||||
```json
|
||||
{
|
||||
"server": {
|
||||
"logLevel": "debug"
|
||||
},
|
||||
"mongo": {
|
||||
"uri": "mongodb://localhost:27017/farmcontrol"
|
||||
},
|
||||
"redis": {
|
||||
"host": "localhost",
|
||||
"port": 6379
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See `config.js` for the full set of supported options.
|
||||
|
||||
## Running the Application
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Production
|
||||
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
## Logging
|
||||
|
||||
The application uses [log4js](https://log4js-node.github.io/log4js-node/) for logging. Set the log level under `server.logLevel` in your configuration:
|
||||
|
||||
```json
|
||||
{
|
||||
"server": {
|
||||
"logLevel": "debug"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Available log levels (least to most verbose): `error`, `warn`, `info`, `debug`, `trace`.
|
||||
59
config.json
Normal file
59
config.json
Normal file
@ -0,0 +1,59 @@
|
||||
{
|
||||
"development": {
|
||||
"server": {
|
||||
"logLevel": "trace"
|
||||
},
|
||||
"database": {
|
||||
"mongo": {
|
||||
"url": "mongodb://127.0.0.1:27017/farmcontrol"
|
||||
},
|
||||
"redis": {
|
||||
"host": "localhost",
|
||||
"port": 6379,
|
||||
"password": ""
|
||||
},
|
||||
"nats": {
|
||||
"host": "localhost",
|
||||
"port": 4222
|
||||
}
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"server": {
|
||||
"logLevel": "error"
|
||||
},
|
||||
"database": {
|
||||
"mongo": {
|
||||
"url": "mongodb://127.0.0.1:27017/farmcontrol-test"
|
||||
},
|
||||
"redis": {
|
||||
"host": "localhost",
|
||||
"port": 6379,
|
||||
"password": ""
|
||||
},
|
||||
"nats": {
|
||||
"host": "localhost",
|
||||
"port": 4222
|
||||
}
|
||||
}
|
||||
},
|
||||
"production": {
|
||||
"server": {
|
||||
"logLevel": "info"
|
||||
},
|
||||
"database": {
|
||||
"mongo": {
|
||||
"url": "mongodb://192.168.68.38:27017/farmcontrol"
|
||||
},
|
||||
"redis": {
|
||||
"host": "localhost",
|
||||
"port": 6379,
|
||||
"password": ""
|
||||
},
|
||||
"nats": {
|
||||
"host": "localhost",
|
||||
"port": 4222
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
63
package.json
Normal file
63
package.json
Normal file
@ -0,0 +1,63 @@
|
||||
{
|
||||
"name": "farmcontrol-scheduler",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"dependencies": {
|
||||
"@nats-io/transport-node": "^3.2.0",
|
||||
"body-parser": "^2.2.0",
|
||||
"canonical-json": "^0.2.0",
|
||||
"dayjs": "^1.11.23",
|
||||
"dotenv": "^17.2.3",
|
||||
"i": "^0.3.7",
|
||||
"ioredis": "^6.0.0",
|
||||
"keycloak-connect": "^26.1.1",
|
||||
"lodash": "^4.17.23",
|
||||
"log4js": "^6.9.1",
|
||||
"mongodb": "^6.21.0",
|
||||
"mongoose": "^9.9.1",
|
||||
"nanoid": "^5.1.6",
|
||||
"node-cache": "^5.1.2",
|
||||
"node-cron": "^4.2.1",
|
||||
"nodemon": "^3.1.11",
|
||||
"pg": "^8.16.3",
|
||||
"sequelize": "^6.37.7"
|
||||
},
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.28.3",
|
||||
"@babel/core": "^7.28.5",
|
||||
"@babel/node": "^7.28.0",
|
||||
"@babel/plugin-proposal-class-properties": "^7.18.6",
|
||||
"@babel/plugin-proposal-object-rest-spread": "^7.20.7",
|
||||
"@babel/preset-env": "^7.28.5",
|
||||
"@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",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.4",
|
||||
"jest": "^30.2.0",
|
||||
"jest-junit": "^16.0.0",
|
||||
"prettier": "^3.6.2",
|
||||
"sequelize-cli": "^6.6.3",
|
||||
"standard": "^17.1.2",
|
||||
"supertest": "^7.1.4"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "node --experimental-vm-modules ./node_modules/jest/bin/jest.js",
|
||||
"start": "node src/index.js",
|
||||
"dev": "nodemon src/index.js",
|
||||
"lint": "eslint src/",
|
||||
"lint:fix": "eslint src/ --fix",
|
||||
"format": "prettier --write \"src/**/*.{js,json}\"",
|
||||
"format:check": "prettier --check \"src/**/*.{js,json}\"",
|
||||
"fix": "npm run lint:fix && npm run format",
|
||||
"ensure-references": "node scripts/ensure-references.js"
|
||||
},
|
||||
"author": "Tom Butcher",
|
||||
"license": "ISC",
|
||||
"packageManager": "pnpm@10.28.0"
|
||||
}
|
||||
11030
pnpm-lock.yaml
generated
Normal file
11030
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
55
src/config.js
Normal file
55
src/config.js
Normal file
@ -0,0 +1,55 @@
|
||||
// config.js - Configuration handling
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
// Load environment variables from .env file
|
||||
dotenv.config();
|
||||
|
||||
// Configure paths relative to this file
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const CONFIG_PATH = path.resolve(__dirname, '../config.json');
|
||||
|
||||
// Load config file
|
||||
export function loadConfig() {
|
||||
const env = process.env.NODE_ENV || 'development';
|
||||
try {
|
||||
if (!fs.existsSync(CONFIG_PATH)) {
|
||||
throw new Error(`Configuration file not found at ${CONFIG_PATH}`);
|
||||
}
|
||||
|
||||
const configData = fs.readFileSync(CONFIG_PATH, 'utf8');
|
||||
const config = JSON.parse(configData);
|
||||
|
||||
if (!config[env]) {
|
||||
throw new Error(
|
||||
`Configuration for environment '${env}' not found in config.json`
|
||||
);
|
||||
}
|
||||
|
||||
const envConfig = config[env];
|
||||
|
||||
// Override keycloak client secret with environment variable if available
|
||||
if (process.env.KEYCLOAK_CLIENT_SECRET) {
|
||||
if (!envConfig.auth) {
|
||||
envConfig.auth = {};
|
||||
}
|
||||
if (!envConfig.auth.keycloak) {
|
||||
envConfig.auth.keycloak = {};
|
||||
}
|
||||
envConfig.auth.keycloak.clientSecret = process.env.KEYCLOAK_CLIENT_SECRET;
|
||||
}
|
||||
|
||||
return envConfig;
|
||||
} catch (err) {
|
||||
console.error('Error loading config:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Get current environment
|
||||
export function getEnvironment() {
|
||||
return process.env.NODE_ENV || 'development';
|
||||
}
|
||||
675
src/database/database.js
Normal file
675
src/database/database.js
Normal file
@ -0,0 +1,675 @@
|
||||
import _ from "lodash";
|
||||
import {
|
||||
deleteAuditLog,
|
||||
expandObjectIds,
|
||||
editAuditLog,
|
||||
editNotification,
|
||||
distributeUpdate,
|
||||
newAuditLog,
|
||||
distributeNew,
|
||||
distributeStats,
|
||||
} from "./utils.js";
|
||||
import log4js from "log4js";
|
||||
import { loadConfig } from "../config.js";
|
||||
import { formatTraceData, getQueryToCacheKey } from "../utils.js";
|
||||
import { redisServer } from "./redis.js";
|
||||
import { auditLogModel } from "./schemas/management/auditlog.schema.js";
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
const logger = log4js.getLogger("Database");
|
||||
const cacheLogger = log4js.getLogger("Local Cache");
|
||||
logger.level = config.server.logLevel;
|
||||
cacheLogger.level = config.server.logLevel;
|
||||
|
||||
const mergeObjectUpdates = (target, source) =>
|
||||
_.mergeWith(target, source, (objValue, srcValue, key) => {
|
||||
if (Array.isArray(objValue) || Array.isArray(srcValue)) {
|
||||
return srcValue;
|
||||
}
|
||||
if (key === "permissions" && srcValue !== undefined) {
|
||||
return srcValue;
|
||||
}
|
||||
});
|
||||
|
||||
// Default cache TTL in seconds (similar to previous in-memory cache)
|
||||
const CACHE_TTL_SECONDS = config.database?.redis?.ttlSeconds || 5;
|
||||
|
||||
export const retrieveObjectCache = async ({ model, id, populate = [] }) => {
|
||||
const cacheKey = getQueryToCacheKey({ model: model.modelName, id, populate });
|
||||
|
||||
cacheLogger.trace("Retrieving:", cacheKey);
|
||||
|
||||
try {
|
||||
const cachedObject = await redisServer.getKey(cacheKey);
|
||||
|
||||
if (cachedObject == null) {
|
||||
cacheLogger.trace("Miss:", cacheKey);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
cacheLogger.trace("Hit:", cacheKey);
|
||||
|
||||
return cachedObject;
|
||||
} catch (err) {
|
||||
cacheLogger.error("Error retrieving object from Redis cache:", err);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
export const updateObjectCache = async ({
|
||||
model,
|
||||
id,
|
||||
object,
|
||||
populate = [],
|
||||
}) => {
|
||||
const cacheKeyFilter = `${model.modelName}:${id?.toString()}*`;
|
||||
const cacheKey = getQueryToCacheKey({ model: model.modelName, id, populate });
|
||||
|
||||
cacheLogger.trace("Updating:", cacheKeyFilter);
|
||||
|
||||
try {
|
||||
// Get all keys matching the filter pattern
|
||||
const matchingKeys = await redisServer.getKeysByPattern(cacheKeyFilter);
|
||||
|
||||
logger.trace(`Matching keys: ${formatTraceData(matchingKeys)}`);
|
||||
|
||||
// Merge the object with each cached object and update
|
||||
const mergedObjects = [];
|
||||
for (const key of matchingKeys) {
|
||||
logger.trace("Updating object cache:", key);
|
||||
const cachedObject = (await redisServer.getKey(key)) || {};
|
||||
const mergedObject = mergeObjectUpdates(cachedObject, object);
|
||||
await redisServer.setKey(key, mergedObject, CACHE_TTL_SECONDS);
|
||||
mergedObjects.push(mergedObject);
|
||||
}
|
||||
|
||||
const cacheObject = (await redisServer.getKey(cacheKey)) || {};
|
||||
const mergedObject = mergeObjectUpdates(cacheObject, object);
|
||||
await redisServer.setKey(cacheKey, mergedObject, CACHE_TTL_SECONDS);
|
||||
|
||||
cacheLogger.trace("Updated:", {
|
||||
filter: cacheKeyFilter,
|
||||
keysUpdated: matchingKeys.length,
|
||||
});
|
||||
|
||||
// Return the merged object
|
||||
return mergedObject;
|
||||
} catch (err) {
|
||||
cacheLogger.error("Error updating object in Redis cache:", err);
|
||||
// Fallback to returning the provided object if cache fails
|
||||
return object;
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteObjectCache = async ({ model, id }) => {
|
||||
const cacheKeyFilter = `${model.modelName}:${id?.toString()}*`;
|
||||
|
||||
cacheLogger.trace("Deleting:", cacheKeyFilter);
|
||||
|
||||
try {
|
||||
// Get all keys matching the filter pattern and delete them
|
||||
const matchingKeys = await redisServer.getKeysByPattern(cacheKeyFilter);
|
||||
|
||||
for (const cacheKey of matchingKeys) {
|
||||
await redisServer.deleteKey(cacheKey);
|
||||
}
|
||||
|
||||
cacheLogger.trace("Deleted:", {
|
||||
filter: cacheKeyFilter,
|
||||
keysDeleted: matchingKeys.length,
|
||||
});
|
||||
} catch (err) {
|
||||
cacheLogger.error("Error deleting object from Redis cache:", err);
|
||||
}
|
||||
};
|
||||
|
||||
// Reusable function to list objects with aggregation, filtering, search, sorting, and pagination
|
||||
export const listObjects = async ({
|
||||
model,
|
||||
populate = [],
|
||||
filter = {},
|
||||
sort = "",
|
||||
order = "ascend",
|
||||
project = {}, // optional: override default projection
|
||||
cached = false,
|
||||
}) => {
|
||||
try {
|
||||
logger.trace(
|
||||
`Listing objects: ${formatTraceData({
|
||||
model,
|
||||
populate,
|
||||
filter,
|
||||
sort,
|
||||
order,
|
||||
project,
|
||||
cached,
|
||||
})}`,
|
||||
);
|
||||
|
||||
// Fix: descend should be -1, ascend should be 1
|
||||
const sortOrder = order === "descend" ? -1 : 1;
|
||||
|
||||
if (!sort || sort === "") {
|
||||
sort = "createdAt";
|
||||
}
|
||||
// Translate parent._id to parent for Mongoose
|
||||
if (filter["parent._id"]) {
|
||||
filter.parent = filter["parent._id"];
|
||||
delete filter["parent._id"];
|
||||
}
|
||||
|
||||
// Translate owner._id to owner for Mongoose
|
||||
if (filter["owner._id"]) {
|
||||
filter.owner = filter["owner._id"];
|
||||
delete filter["owner._id"];
|
||||
}
|
||||
|
||||
// Use find with population and filter
|
||||
let query = model.find(filter).sort({ [sort]: sortOrder });
|
||||
|
||||
// Handle populate (array or single value)
|
||||
if (populate.length > 0) {
|
||||
if (Array.isArray(populate)) {
|
||||
for (const pop of populate) {
|
||||
query = query.populate(pop);
|
||||
}
|
||||
} else if (typeof populate === "string" || typeof populate === "object") {
|
||||
query = query.populate(populate);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle select (projection)
|
||||
if (project && Object.keys(project).length > 0) {
|
||||
query = query.select(project);
|
||||
}
|
||||
|
||||
query = query.lean();
|
||||
|
||||
const queryResult = await query;
|
||||
|
||||
const finalResult = expandObjectIds(queryResult);
|
||||
|
||||
logger.trace(
|
||||
`Retreived from database: ${formatTraceData({
|
||||
model,
|
||||
populate,
|
||||
filter,
|
||||
sort,
|
||||
order,
|
||||
project,
|
||||
cached,
|
||||
length: finalResult.length,
|
||||
})}`,
|
||||
);
|
||||
return finalResult;
|
||||
} catch (error) {
|
||||
logger.error("Object list error:", error);
|
||||
return { error: error, code: 500 };
|
||||
}
|
||||
};
|
||||
|
||||
// Reusable function to get a single object by ID
|
||||
export const getObject = async ({
|
||||
model,
|
||||
id,
|
||||
populate = [],
|
||||
cached = false,
|
||||
}) => {
|
||||
try {
|
||||
logger.trace(
|
||||
`Getting object: ${formatTraceData({
|
||||
model,
|
||||
id,
|
||||
populate,
|
||||
})}`,
|
||||
);
|
||||
|
||||
if (cached == true) {
|
||||
const cachedObject = await retrieveObjectCache({ model, id, populate });
|
||||
if (cachedObject != undefined) {
|
||||
return cachedObject;
|
||||
}
|
||||
}
|
||||
|
||||
let query = model.findById(id).lean();
|
||||
|
||||
// Handle populate (array or single value)
|
||||
if (populate) {
|
||||
if (Array.isArray(populate)) {
|
||||
for (const pop of populate) {
|
||||
query = query.populate(pop);
|
||||
}
|
||||
} else if (typeof populate === "string" || typeof populate === "object") {
|
||||
query = query.populate(populate);
|
||||
}
|
||||
}
|
||||
const finalResult = await query;
|
||||
|
||||
if (!finalResult) {
|
||||
logger.warn("Object not found in database:", {
|
||||
model,
|
||||
id,
|
||||
populate,
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
|
||||
logger.trace(
|
||||
`Retreived object from database: ${formatTraceData({
|
||||
model,
|
||||
id,
|
||||
populate,
|
||||
})}`,
|
||||
);
|
||||
|
||||
logger.trace(formatTraceData(finalResult));
|
||||
|
||||
await updateObjectCache({
|
||||
model: model,
|
||||
id: finalResult._id.toString(),
|
||||
populate,
|
||||
object: finalResult,
|
||||
});
|
||||
|
||||
return finalResult;
|
||||
} catch (error) {
|
||||
logger.error("An error retreiving object:", error.message);
|
||||
throw error;
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
// Utility to run one or many rollup aggregations in a single query via $facet.
|
||||
export const aggregateRollups = async ({
|
||||
model,
|
||||
baseFilter = {},
|
||||
rollupConfigs = [],
|
||||
}) => {
|
||||
if (!rollupConfigs.length) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const facetStage = rollupConfigs.reduce((facets, definition, index) => {
|
||||
const key = definition.name || `rollup${index}`;
|
||||
const matchStage = {
|
||||
$match: { ...baseFilter, ...(definition.filter || {}) },
|
||||
};
|
||||
const groupStage = { $group: { _id: null } };
|
||||
|
||||
(definition.rollups || []).forEach((rollup) => {
|
||||
switch (rollup.operation) {
|
||||
case "sum":
|
||||
groupStage.$group[rollup.name] = { $sum: `$${rollup.property}` };
|
||||
break;
|
||||
case "count":
|
||||
groupStage.$group[rollup.name] = { $sum: 1 };
|
||||
break;
|
||||
case "avg":
|
||||
groupStage.$group[rollup.name] = { $avg: `$${rollup.property}` };
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported rollup operation: ${rollup.operation}`);
|
||||
}
|
||||
});
|
||||
|
||||
facets[key] = [matchStage, groupStage];
|
||||
return facets;
|
||||
}, {});
|
||||
|
||||
const [results] = await model.aggregate([{ $facet: facetStage }]);
|
||||
|
||||
return rollupConfigs.reduce((acc, definition, index) => {
|
||||
const key = definition.name || `rollup${index}`;
|
||||
const rawResult = results?.[key]?.[0] || {};
|
||||
|
||||
// Transform the result to nest rollup values under operation type
|
||||
const transformedResult = {};
|
||||
(definition.rollups || []).forEach((rollup) => {
|
||||
const value = rawResult[rollup.name] || 0;
|
||||
// If there's only one rollup and its name matches the key, flatten the structure
|
||||
if (definition.rollups.length === 1 && rollup.name === key) {
|
||||
transformedResult[rollup.operation] = value;
|
||||
} else {
|
||||
transformedResult[rollup.name] = { [rollup.operation]: value };
|
||||
}
|
||||
});
|
||||
|
||||
acc[key] = transformedResult;
|
||||
return acc;
|
||||
}, {});
|
||||
};
|
||||
|
||||
// Snapshot absolute rollup values at each point in time by reconstructing
|
||||
// object state from the current documents plus audit logs.
|
||||
export const aggregateRollupsHistory = async ({
|
||||
model,
|
||||
baseFilter = {},
|
||||
rollupConfigs = [],
|
||||
startDate,
|
||||
endDate,
|
||||
}) => {
|
||||
if (!rollupConfigs.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const end = endDate ? new Date(endDate) : new Date();
|
||||
const start = startDate
|
||||
? new Date(startDate)
|
||||
: new Date(end.getTime() - 24 * 60 * 60 * 1000);
|
||||
const parentType = model.modelName ? model.modelName : "unknown";
|
||||
|
||||
const matchesFilter = (obj, filter) => {
|
||||
if (!filter || Object.keys(filter).length === 0) return true;
|
||||
|
||||
for (const [key, expectedValue] of Object.entries(filter)) {
|
||||
const actualValue = _.get(obj, key);
|
||||
if (actualValue != expectedValue) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const existedAt = (obj, bucketDate) => {
|
||||
if (!obj?.createdAt) return true;
|
||||
return new Date(obj.createdAt) <= bucketDate;
|
||||
};
|
||||
|
||||
const snapshotRollups = (objects, bucketDate) => {
|
||||
const bucketResult = {
|
||||
date: bucketDate.toISOString(),
|
||||
};
|
||||
|
||||
rollupConfigs.forEach((config) => {
|
||||
const matchingObjects = objects.filter(
|
||||
(obj) =>
|
||||
existedAt(obj, bucketDate) &&
|
||||
matchesFilter(obj, baseFilter) &&
|
||||
matchesFilter(obj, config.filter),
|
||||
);
|
||||
|
||||
(config.rollups || []).forEach((rollup) => {
|
||||
let value = 0;
|
||||
if (rollup.operation === "count") {
|
||||
value = matchingObjects.length;
|
||||
} else if (rollup.operation === "sum") {
|
||||
value = _.sumBy(
|
||||
matchingObjects,
|
||||
(obj) => _.get(obj, rollup.property) || 0,
|
||||
);
|
||||
} else if (rollup.operation === "avg") {
|
||||
const sum = _.sumBy(
|
||||
matchingObjects,
|
||||
(obj) => _.get(obj, rollup.property) || 0,
|
||||
);
|
||||
value = matchingObjects.length ? sum / matchingObjects.length : 0;
|
||||
}
|
||||
|
||||
bucketResult[rollup.name] = { [rollup.operation]: value };
|
||||
});
|
||||
});
|
||||
|
||||
return bucketResult;
|
||||
};
|
||||
|
||||
const auditLogs = await auditLogModel
|
||||
.find({
|
||||
parentType,
|
||||
createdAt: { $gte: start },
|
||||
})
|
||||
.sort({ createdAt: -1 })
|
||||
.lean();
|
||||
|
||||
const currentObjects = await model.find(baseFilter).lean();
|
||||
const objectMap = new Map();
|
||||
currentObjects.forEach((obj) => {
|
||||
objectMap.set(obj._id.toString(), expandObjectIds(obj));
|
||||
});
|
||||
|
||||
const extraIds = [
|
||||
...new Set(
|
||||
auditLogs
|
||||
.map((log) => log.parent?.toString())
|
||||
.filter((id) => id && !objectMap.has(id)),
|
||||
),
|
||||
];
|
||||
if (extraIds.length) {
|
||||
const extraObjects = await model.find({ _id: { $in: extraIds } }).lean();
|
||||
extraObjects.forEach((obj) => {
|
||||
objectMap.set(obj._id.toString(), expandObjectIds(obj));
|
||||
});
|
||||
}
|
||||
|
||||
if (objectMap.size === 0 && auditLogs.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const buckets = [];
|
||||
let currentTime = new Date(end);
|
||||
currentTime.setSeconds(0, 0);
|
||||
|
||||
while (currentTime >= start) {
|
||||
buckets.push(new Date(currentTime));
|
||||
currentTime = new Date(currentTime.getTime() - 60000);
|
||||
}
|
||||
|
||||
if (!buckets.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const workingObjects = new Map();
|
||||
objectMap.forEach((val, key) => workingObjects.set(key, _.cloneDeep(val)));
|
||||
|
||||
const results = [];
|
||||
let logIndex = 0;
|
||||
|
||||
for (const bucketDate of buckets) {
|
||||
while (logIndex < auditLogs.length) {
|
||||
const log = auditLogs[logIndex];
|
||||
const logDate = new Date(log.createdAt);
|
||||
|
||||
if (logDate <= bucketDate) {
|
||||
break;
|
||||
}
|
||||
|
||||
const objectId = log.parent.toString();
|
||||
const object = workingObjects.get(objectId);
|
||||
|
||||
if (log.operation === "new") {
|
||||
workingObjects.delete(objectId);
|
||||
} else if (log.operation === "delete" && log.changes?.old) {
|
||||
if (!workingObjects.has(objectId)) {
|
||||
workingObjects.set(
|
||||
objectId,
|
||||
expandObjectIds({ ...log.changes.old, _id: log.parent }),
|
||||
);
|
||||
}
|
||||
} else if (object && log.changes?.old) {
|
||||
mergeObjectUpdates(object, log.changes.old);
|
||||
}
|
||||
|
||||
logIndex++;
|
||||
}
|
||||
|
||||
results.push(
|
||||
snapshotRollups(Array.from(workingObjects.values()), bucketDate),
|
||||
);
|
||||
}
|
||||
|
||||
return results.reverse();
|
||||
};
|
||||
|
||||
export const getModelStats = async ({ model }) => {
|
||||
if (!model.stats) {
|
||||
return { error: "Model does not have a stats method.", code: 500 };
|
||||
}
|
||||
return await model.stats();
|
||||
};
|
||||
|
||||
// Reusable function to edit an object by ID, with audit logging and distribution
|
||||
export const editObject = async ({
|
||||
model,
|
||||
id,
|
||||
updateData,
|
||||
owner = undefined,
|
||||
ownerType = undefined,
|
||||
populate = [],
|
||||
auditLog = true,
|
||||
notify = true,
|
||||
recalculate = true,
|
||||
}) => {
|
||||
try {
|
||||
// Determine parentType from model name
|
||||
const parentType = model.modelName ? model.modelName : "unknown";
|
||||
// Fetch the and update object
|
||||
var query = model.findByIdAndUpdate(id, updateData).lean();
|
||||
var newQuery = model.findById(id).lean();
|
||||
|
||||
if (populate) {
|
||||
if (Array.isArray(populate)) {
|
||||
for (const pop of populate) {
|
||||
query = query.populate(pop);
|
||||
newQuery = newQuery.populate(pop);
|
||||
}
|
||||
} else if (typeof populate === "string" || typeof populate === "object") {
|
||||
query = query.populate(populate);
|
||||
newQuery = newQuery.populate(populate);
|
||||
}
|
||||
}
|
||||
|
||||
const previousObject = await query;
|
||||
const newObject = await newQuery;
|
||||
if (!previousObject || !newObject) {
|
||||
return { error: `${parentType} not found.`, code: 404 };
|
||||
}
|
||||
const previousExpandedObject = expandObjectIds(previousObject);
|
||||
const newExpandedObject = expandObjectIds(newObject);
|
||||
|
||||
if (auditLog == true && owner != undefined && ownerType != undefined) {
|
||||
// Audit log before update
|
||||
await editAuditLog(
|
||||
previousExpandedObject,
|
||||
newExpandedObject,
|
||||
id,
|
||||
parentType,
|
||||
owner,
|
||||
ownerType,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
notify == true &&
|
||||
owner != undefined &&
|
||||
ownerType != undefined &&
|
||||
parentType !== "notification" &&
|
||||
parentType !== "auditLog" &&
|
||||
parentType !== "userNotifier" &&
|
||||
parentType !== "objectView"
|
||||
) {
|
||||
await editNotification(
|
||||
previousExpandedObject,
|
||||
newExpandedObject,
|
||||
id,
|
||||
parentType,
|
||||
owner,
|
||||
ownerType,
|
||||
);
|
||||
}
|
||||
|
||||
// Distribute update
|
||||
await distributeUpdate(updateData, id, parentType);
|
||||
|
||||
await updateObjectCache({
|
||||
model: model,
|
||||
id: id.toString(),
|
||||
object: { ...previousExpandedObject, ...updateData },
|
||||
populate,
|
||||
});
|
||||
|
||||
if (model.recalculate && recalculate == true) {
|
||||
logger.debug(`Recalculating ${model.modelName}`);
|
||||
await model.recalculate(newExpandedObject, owner, ownerType);
|
||||
}
|
||||
|
||||
if (model.stats) {
|
||||
logger.debug(`Getting stats for ${model.modelName}`);
|
||||
const statsData = await model.stats(newExpandedObject);
|
||||
await distributeStats(statsData, parentType);
|
||||
}
|
||||
|
||||
return { ...previousExpandedObject, ...updateData };
|
||||
} catch (error) {
|
||||
logger.error("editObject error:", error);
|
||||
return { error: error.message, code: 500 };
|
||||
}
|
||||
};
|
||||
|
||||
// Reusable function to create a new object
|
||||
export const newObject = async ({
|
||||
model,
|
||||
newData,
|
||||
owner = null,
|
||||
ownerType = undefined,
|
||||
}) => {
|
||||
try {
|
||||
const parentType = model.modelName ? model.modelName : "unknown";
|
||||
|
||||
const result = await model.create(newData);
|
||||
if (!result || result.length === 0) {
|
||||
return { error: "No object created.", code: 500 };
|
||||
}
|
||||
const created = result;
|
||||
|
||||
if (owner != undefined && ownerType != undefined) {
|
||||
await newAuditLog(newData, created._id, parentType, owner, ownerType);
|
||||
}
|
||||
|
||||
await distributeNew(created, parentType);
|
||||
|
||||
await updateObjectCache({
|
||||
model: model,
|
||||
id: created._id.toString(),
|
||||
object: { _id: created._id, ...newData },
|
||||
populate: [],
|
||||
});
|
||||
|
||||
return created;
|
||||
} catch (error) {
|
||||
logger.error("newObject error:", error);
|
||||
return { error: error.message, code: 500 };
|
||||
}
|
||||
};
|
||||
|
||||
// Reusable function to delete an object by ID, with audit logging and distribution
|
||||
export const deleteObject = async ({
|
||||
model,
|
||||
id,
|
||||
owner = null,
|
||||
ownerType = undefined,
|
||||
}) => {
|
||||
try {
|
||||
const parentType = model.modelName ? model.modelName : "unknown";
|
||||
// Delete the object
|
||||
const result = await model.findByIdAndDelete(id);
|
||||
|
||||
if (!result) {
|
||||
return { error: `${parentType} not found.`, code: 404 };
|
||||
}
|
||||
|
||||
if (owner != undefined && ownerType != undefined) {
|
||||
// Audit log the deletion
|
||||
await deleteAuditLog(result, id, parentType, owner, ownerType);
|
||||
}
|
||||
|
||||
deleteObjectCache({ model: model, id: id.toString() });
|
||||
|
||||
// Distribute the deletion event
|
||||
await distributeUpdate({ deleted: true }, id, parentType);
|
||||
|
||||
return { deleted: true, id: id.toString() };
|
||||
} catch (error) {
|
||||
logger.error("deleteObject error:", error);
|
||||
return { error: error.message, code: 500 };
|
||||
}
|
||||
};
|
||||
1371
src/database/filter.js
Normal file
1371
src/database/filter.js
Normal file
File diff suppressed because it is too large
Load Diff
59
src/database/mongo.js
Normal file
59
src/database/mongo.js
Normal file
@ -0,0 +1,59 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { loadConfig } from '../config.js';
|
||||
import log4js from 'log4js';
|
||||
|
||||
const config = loadConfig();
|
||||
const logger = log4js.getLogger('Mongo DB');
|
||||
logger.level = config.server.logLevel;
|
||||
|
||||
class MongoServer {
|
||||
constructor() {
|
||||
this.connected = false;
|
||||
this.connecting = false;
|
||||
this.connectionPromise = null;
|
||||
this.url = config.database.mongo.url;
|
||||
}
|
||||
|
||||
connect() {
|
||||
if (this.connected) return mongoose.connection;
|
||||
if (this.connecting) return this.connectionPromise;
|
||||
this.connecting = true;
|
||||
logger.info('Connecting to MongoDB...');
|
||||
logger.debug('Connection URL:', this.url);
|
||||
this.connectionPromise = mongoose
|
||||
.connect(this.url, {})
|
||||
.then(conn => {
|
||||
this.connected = true;
|
||||
logger.info('Database connected.');
|
||||
return conn.connection;
|
||||
})
|
||||
.catch(err => {
|
||||
this.connected = false;
|
||||
logger.error('MongoDB connection error:', err);
|
||||
throw err;
|
||||
})
|
||||
.finally(() => {
|
||||
this.connecting = false;
|
||||
});
|
||||
return this.connectionPromise;
|
||||
}
|
||||
|
||||
async getConnection() {
|
||||
if (!this.connected) {
|
||||
await this.connect();
|
||||
}
|
||||
return mongoose.connection;
|
||||
}
|
||||
|
||||
async disconnect() {
|
||||
if (!this.connected) return;
|
||||
logger.info('Disconnecting from MongoDB...');
|
||||
await mongoose.connection.close();
|
||||
this.connected = false;
|
||||
logger.info('Disconnected from MongoDB');
|
||||
}
|
||||
}
|
||||
|
||||
const mongoServer = new MongoServer();
|
||||
|
||||
export { MongoServer, mongoServer };
|
||||
310
src/database/nats.js
Normal file
310
src/database/nats.js
Normal file
@ -0,0 +1,310 @@
|
||||
import { connect } from '@nats-io/transport-node';
|
||||
import log4js from 'log4js';
|
||||
import { loadConfig } from '../config.js';
|
||||
import { formatTraceData } from '../utils.js';
|
||||
|
||||
const config = loadConfig();
|
||||
const logger = log4js.getLogger('Nats');
|
||||
logger.level = config.server.logLevel;
|
||||
|
||||
class NatsServer {
|
||||
constructor() {
|
||||
this.client = null;
|
||||
this.subscriptions = new Map(); // subject → { subscription, callbacks }
|
||||
this.requestHandlers = new Map(); // subject → { handler, callbacks }
|
||||
this.queuedSubscriptions = new Map(); // subject → { subscription, callbacks, queue }
|
||||
|
||||
const natsConfig = config.database?.nats || config.database; // fallback for production config
|
||||
const host = natsConfig.host || 'localhost';
|
||||
const port = natsConfig.port || 4222;
|
||||
this.servers = [`nats://${host}:${port}`];
|
||||
this.textEncoder = new TextEncoder();
|
||||
this.textDecoder = new TextDecoder();
|
||||
|
||||
logger.trace(`NatsServer: servers set to ${JSON.stringify(this.servers)}`);
|
||||
}
|
||||
|
||||
async connect() {
|
||||
if (!this.client) {
|
||||
logger.info('Connecting to NATS...');
|
||||
logger.trace(
|
||||
`Creating NATS client with servers ${JSON.stringify(this.servers)}`
|
||||
);
|
||||
|
||||
try {
|
||||
this.client = await connect({
|
||||
servers: this.servers,
|
||||
reconnect: true,
|
||||
maxReconnectAttempts: -1, // unlimited reconnects
|
||||
reconnectTimeWait: 1000,
|
||||
timeout: 20000
|
||||
});
|
||||
|
||||
// Test connection by checking if client is connected
|
||||
try {
|
||||
if (this.client.isClosed()) {
|
||||
throw new Error('NATS client connection failed');
|
||||
}
|
||||
logger.trace('NATS client connected successfully.');
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to connect to NATS:', error);
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
logger.trace('NATS client already exists, skipping connection.');
|
||||
}
|
||||
return this.client;
|
||||
}
|
||||
|
||||
async getClient() {
|
||||
if (!this.client) {
|
||||
logger.trace('No client found, calling connect().');
|
||||
await this.connect();
|
||||
}
|
||||
return this.client;
|
||||
}
|
||||
|
||||
async publish(subject, data) {
|
||||
const client = await this.getClient();
|
||||
const payload = typeof data === 'string' ? data : JSON.stringify(data);
|
||||
|
||||
try {
|
||||
client.publish(subject, this.textEncoder.encode(payload));
|
||||
logger.trace(`Published to subject: ${subject}, data: ${formatTraceData(payload)}`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error(`Failed to publish to subject ${subject}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async request(subject, data, timeout = 30000) {
|
||||
const client = await this.getClient();
|
||||
const payload = typeof data === 'string' ? data : JSON.stringify(data);
|
||||
|
||||
try {
|
||||
const response = await client.request(
|
||||
subject,
|
||||
this.textEncoder.encode(payload),
|
||||
{
|
||||
timeout: timeout
|
||||
}
|
||||
);
|
||||
|
||||
const responseData = this.textDecoder.decode(response.data);
|
||||
logger.trace(
|
||||
`Request to subject: ${subject}, response: ${formatTraceData(responseData)}`
|
||||
);
|
||||
|
||||
// Try to parse as JSON, fallback to string
|
||||
try {
|
||||
return JSON.parse(responseData);
|
||||
} catch {
|
||||
return responseData;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code === 'TIMEOUT') {
|
||||
logger.trace(`Request timeout for subject: ${subject}`);
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async subscribe(subject, owner, callback) {
|
||||
const client = await this.getClient();
|
||||
const subscriptionKey = subject;
|
||||
|
||||
if (this.subscriptions.has(subscriptionKey)) {
|
||||
this.subscriptions.get(subscriptionKey).callbacks.set(owner, callback);
|
||||
logger.trace(
|
||||
`Added subscription callback for owner=${owner} on subject=${subject}`
|
||||
);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
logger.trace(`Creating new subscription for subject: ${subject}`);
|
||||
const subscription = client.subscribe(subject);
|
||||
const callbacks = new Map();
|
||||
callbacks.set(owner, callback);
|
||||
|
||||
(async () => {
|
||||
for await (const msg of subscription) {
|
||||
logger.trace(`Message received on subject: ${subject}`);
|
||||
const data = this.textDecoder.decode(msg.data);
|
||||
let parsedData;
|
||||
|
||||
try {
|
||||
parsedData = JSON.parse(data);
|
||||
} catch {
|
||||
parsedData = data;
|
||||
}
|
||||
|
||||
for (const [ownerId, cb] of callbacks) {
|
||||
try {
|
||||
cb(subject, parsedData, msg);
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`Error in subscription callback for owner=${ownerId}, subject=${subject}:`,
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
})().catch(err => {
|
||||
logger.error(`Subscription error for subject ${subject}:`, err);
|
||||
});
|
||||
|
||||
this.subscriptions.set(subscriptionKey, { subscription, callbacks });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async setRequestHandler(subject, owner, handler) {
|
||||
const client = await this.getClient();
|
||||
const handlerKey = subject;
|
||||
|
||||
if (this.requestHandlers.has(handlerKey)) {
|
||||
this.requestHandlers.get(handlerKey).callbacks.set(owner, handler);
|
||||
logger.trace(
|
||||
`Added request handler for owner=${owner} on subject=${subject}`
|
||||
);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
logger.trace(`Creating new request handler for subject: ${subject}`);
|
||||
const subscription = client.subscribe(subject);
|
||||
const callbacks = new Map();
|
||||
callbacks.set(owner, handler);
|
||||
|
||||
(async () => {
|
||||
for await (const msg of subscription) {
|
||||
logger.trace(`Request received on subject: ${subject}`);
|
||||
const data = this.textDecoder.decode(msg.data);
|
||||
let parsedData;
|
||||
|
||||
try {
|
||||
parsedData = JSON.parse(data);
|
||||
} catch {
|
||||
parsedData = data;
|
||||
}
|
||||
|
||||
for (const [ownerId, cb] of callbacks) {
|
||||
try {
|
||||
const response = await cb(subject, parsedData, msg);
|
||||
const responsePayload =
|
||||
typeof response === 'string'
|
||||
? response
|
||||
: JSON.stringify(response);
|
||||
msg.respond(this.textEncoder.encode(responsePayload));
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`Error in request handler for owner=${ownerId}, subject=${subject}:`,
|
||||
err
|
||||
);
|
||||
// Send error response
|
||||
msg.respond(
|
||||
this.textEncoder.encode(JSON.stringify({ error: err.message }))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
})().catch(err => {
|
||||
logger.error(`Request handler error for subject ${subject}:`, err);
|
||||
});
|
||||
|
||||
this.requestHandlers.set(handlerKey, { subscription, callbacks });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async removeSubscription(subject, owner) {
|
||||
const entry = this.subscriptions.get(subject);
|
||||
|
||||
if (!entry) {
|
||||
logger.trace(`Subscription not found for subject: ${subject}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (entry.callbacks.delete(owner)) {
|
||||
logger.trace(
|
||||
`Removed subscription callback for owner: ${owner} on subject: ${subject}`
|
||||
);
|
||||
} else {
|
||||
logger.trace(
|
||||
`No subscription callback found for owner: ${owner} on subject: ${subject}`
|
||||
);
|
||||
}
|
||||
|
||||
if (entry.callbacks.size === 0) {
|
||||
logger.trace(`No callbacks left, stopping subscription for ${subject}`);
|
||||
entry.subscription.unsubscribe();
|
||||
this.subscriptions.delete(subject);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async removeRequestHandler(subject, owner) {
|
||||
const entry = this.requestHandlers.get(subject);
|
||||
|
||||
if (!entry) {
|
||||
logger.trace(`Request handler not found for subject: ${subject}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (entry.callbacks.delete(owner)) {
|
||||
logger.trace(
|
||||
`Removed request handler for owner: ${owner} on subject: ${subject}`
|
||||
);
|
||||
} else {
|
||||
logger.trace(
|
||||
`No request handler found for owner: ${owner} on subject: ${subject}`
|
||||
);
|
||||
}
|
||||
|
||||
if (entry.callbacks.size === 0) {
|
||||
logger.trace(`No handlers left, stopping request handler for ${subject}`);
|
||||
entry.subscription.unsubscribe();
|
||||
this.requestHandlers.delete(subject);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async disconnect() {
|
||||
logger.info('Disconnecting from NATS...');
|
||||
|
||||
// Stop all subscriptions
|
||||
for (const [subject, entry] of this.subscriptions) {
|
||||
logger.trace(`Stopping subscription: ${subject}`);
|
||||
entry.subscription.unsubscribe();
|
||||
}
|
||||
this.subscriptions.clear();
|
||||
|
||||
// Stop all queued subscriptions
|
||||
for (const [key, entry] of this.queuedSubscriptions) {
|
||||
logger.trace(`Stopping queued subscription: ${key}`);
|
||||
entry.subscription.unsubscribe();
|
||||
}
|
||||
this.queuedSubscriptions.clear();
|
||||
|
||||
// Stop all request handlers
|
||||
for (const [subject, entry] of this.requestHandlers) {
|
||||
logger.trace(`Stopping request handler: ${subject}`);
|
||||
entry.subscription.unsubscribe();
|
||||
}
|
||||
this.requestHandlers.clear();
|
||||
|
||||
if (this.client) {
|
||||
await this.client.close();
|
||||
this.client = null;
|
||||
logger.info('Disconnected from NATS');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const natsServer = new NatsServer();
|
||||
|
||||
export { NatsServer, natsServer };
|
||||
78
src/database/permissions.js
Normal file
78
src/database/permissions.js
Normal file
@ -0,0 +1,78 @@
|
||||
import mongoose from "mongoose";
|
||||
import { redisServer } from "./redis.js";
|
||||
|
||||
export const getUserPermissionsCacheKey = (userId) => `permissions:${userId}`;
|
||||
|
||||
export const saveUserPermissionsToRedis = async (userId, permissions = {}) => {
|
||||
if (!userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await redisServer.setKey(
|
||||
getUserPermissionsCacheKey(userId),
|
||||
permissions || {},
|
||||
);
|
||||
};
|
||||
|
||||
export const applyPermissionSettingsList = (settingsList = []) => {
|
||||
const permissions = {};
|
||||
|
||||
for (const setting of settingsList) {
|
||||
const matrix = setting?.permissions;
|
||||
if (!matrix || typeof matrix !== "object" || Array.isArray(matrix)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const [modelName, actions] of Object.entries(matrix)) {
|
||||
if (!actions || typeof actions !== "object" || Array.isArray(actions)) {
|
||||
continue;
|
||||
}
|
||||
if (!permissions[modelName]) {
|
||||
permissions[modelName] = {};
|
||||
}
|
||||
for (const [actionName, value] of Object.entries(actions)) {
|
||||
if (value === true || value === false) {
|
||||
permissions[modelName][actionName] = value;
|
||||
}
|
||||
}
|
||||
if (Object.keys(permissions[modelName]).length === 0) {
|
||||
delete permissions[modelName];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return permissions;
|
||||
};
|
||||
|
||||
export const getPermissionSettingsId = (value) => {
|
||||
if (!value) return null;
|
||||
if (typeof value === "string") return value;
|
||||
if (value._id) {
|
||||
return value._id._id || value._id;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
export const excludeIdFromList = (items = [], id) =>
|
||||
(items || []).filter(
|
||||
(item) => String(getPermissionSettingsId(item)) !== String(id),
|
||||
);
|
||||
|
||||
export const resolveReferencedDocs = async (modelName, items = []) => {
|
||||
const ids = (items || []).map(getPermissionSettingsId).filter(Boolean);
|
||||
if (ids.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const docs = await mongoose
|
||||
.model(modelName)
|
||||
.find({ _id: { $in: ids } })
|
||||
.lean();
|
||||
const docsById = new Map(docs.map((doc) => [String(doc._id), doc]));
|
||||
|
||||
return ids.map((id) => docsById.get(String(id))).filter(Boolean);
|
||||
};
|
||||
|
||||
export const resolvePermissionSettings = async (owner) => {
|
||||
return resolveReferencedDocs("permissionSetting", owner?.permissionSettings);
|
||||
};
|
||||
110
src/database/redis.js
Normal file
110
src/database/redis.js
Normal file
@ -0,0 +1,110 @@
|
||||
import Redis from "ioredis";
|
||||
import log4js from "log4js";
|
||||
import { loadConfig } from "../config.js";
|
||||
|
||||
const config = loadConfig();
|
||||
const logger = log4js.getLogger("Redis");
|
||||
logger.level = config.server.logLevel;
|
||||
|
||||
class RedisServer {
|
||||
constructor() {
|
||||
const url =
|
||||
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, {
|
||||
password,
|
||||
lazyConnect: true,
|
||||
});
|
||||
|
||||
this.client.on("error", (err) => {
|
||||
logger.error("Redis Client Error", err);
|
||||
});
|
||||
|
||||
this.connected = false;
|
||||
}
|
||||
|
||||
async connect() {
|
||||
if (this.connected) return;
|
||||
logger.info("Connecting to Redis...");
|
||||
await this.client.connect();
|
||||
this.connected = true;
|
||||
logger.info("Connected to Redis");
|
||||
}
|
||||
|
||||
async disconnect() {
|
||||
await this.client.disconnect();
|
||||
this.connected = false;
|
||||
logger.info("Disconnected from Redis");
|
||||
}
|
||||
|
||||
async setKey(key, value, ttlSeconds) {
|
||||
await this.connect();
|
||||
const payload = typeof value === "string" ? value : JSON.stringify(value);
|
||||
if (ttlSeconds) {
|
||||
await this.client.set(key, payload, "EX", ttlSeconds);
|
||||
} else {
|
||||
await this.client.set(key, payload);
|
||||
}
|
||||
}
|
||||
|
||||
async getKey(key) {
|
||||
await this.connect();
|
||||
const value = await this.client.get(key);
|
||||
if (value == null) return null;
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteKey(key) {
|
||||
await this.connect();
|
||||
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) {
|
||||
await this.connect();
|
||||
const keys = [];
|
||||
let cursor = "0";
|
||||
do {
|
||||
const [nextCursor, foundKeys] = await this.client.scan(
|
||||
cursor,
|
||||
"MATCH",
|
||||
pattern,
|
||||
"COUNT",
|
||||
100,
|
||||
);
|
||||
cursor = nextCursor;
|
||||
keys.push(...foundKeys);
|
||||
} while (cursor !== "0");
|
||||
return keys;
|
||||
}
|
||||
}
|
||||
|
||||
const redisServer = new RedisServer();
|
||||
|
||||
export { RedisServer, redisServer };
|
||||
416
src/database/schemas/__tests__/stockQuantity.recalculate.test.js
Normal file
416
src/database/schemas/__tests__/stockQuantity.recalculate.test.js
Normal file
@ -0,0 +1,416 @@
|
||||
import { beforeEach, describe, expect, it, jest } from '@jest/globals';
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
jest.unstable_mockModule('../../database.js', () => ({
|
||||
searchObjects: jest.fn(),
|
||||
getPropertyValues: jest.fn(),
|
||||
listObjects: jest.fn(),
|
||||
getObject: jest.fn(),
|
||||
editObject: jest.fn(),
|
||||
editObjects: jest.fn(),
|
||||
newObject: jest.fn(),
|
||||
deleteObject: jest.fn(),
|
||||
listObjectsByProperties: jest.fn(),
|
||||
getModelStats: jest.fn(),
|
||||
getModelHistory: jest.fn(),
|
||||
aggregateRollups: jest.fn(),
|
||||
aggregateRollupsHistory: jest.fn(),
|
||||
checkStates: jest.fn(),
|
||||
getObjectNeighbors: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule('../../utils.js', () => ({
|
||||
generateId: jest.fn(() => () => 'test-id'),
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule('../inventory/stockaudit.schema.js', () => ({
|
||||
updateDraftStockAuditCurrents: jest.fn().mockResolvedValue(),
|
||||
}));
|
||||
|
||||
const { aggregateRollups, editObject, newObject, deleteObject } = await import('../../database.js');
|
||||
const { listingModel } = await import('../sales/listing.schema.js');
|
||||
const { listingVarientModel } = await import('../sales/listingvarient.schema.js');
|
||||
const { productSkuModel } = await import('../management/productsku.schema.js');
|
||||
const { productStockModel } = await import('../inventory/productstock.schema.js');
|
||||
|
||||
const listingId = new mongoose.Types.ObjectId();
|
||||
const productId = new mongoose.Types.ObjectId();
|
||||
const productSkuId = new mongoose.Types.ObjectId();
|
||||
const stockLocationId = new mongoose.Types.ObjectId();
|
||||
const varientId = new mongoose.Types.ObjectId();
|
||||
|
||||
const mockFind = (docs) => ({
|
||||
sort: () => ({ lean: async () => docs }),
|
||||
});
|
||||
|
||||
describe('listing.recalculate', () => {
|
||||
beforeEach(() => {
|
||||
editObject.mockReset();
|
||||
newObject.mockReset();
|
||||
deleteObject.mockReset();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('calls recalculate on each listing varient', async () => {
|
||||
const recalculate = jest.spyOn(listingVarientModel, 'recalculate').mockResolvedValue();
|
||||
jest.spyOn(listingVarientModel, 'find').mockReturnValue(
|
||||
mockFind([{ _id: varientId, listing: listingId }])
|
||||
);
|
||||
|
||||
const listing = { _id: listingId, stockLocation: stockLocationId };
|
||||
await listingModel.recalculate(listing, 'user-1');
|
||||
|
||||
expect(listingVarientModel.find).toHaveBeenCalledWith({ listing: listingId });
|
||||
expect(recalculate).toHaveBeenCalledWith({ _id: varientId, listing: listingId }, 'user-1');
|
||||
expect(newObject).not.toHaveBeenCalled();
|
||||
expect(deleteObject).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates listing varients from product skus when none exist', async () => {
|
||||
const skuBId = new mongoose.Types.ObjectId();
|
||||
const createdVarientId = new mongoose.Types.ObjectId();
|
||||
const productSkus = [{ _id: productSkuId }, { _id: skuBId }];
|
||||
const syncedVarients = [
|
||||
{ _id: varientId, listing: listingId, product: productId, productSku: productSkuId },
|
||||
{ _id: createdVarientId, listing: listingId, product: productId, productSku: skuBId },
|
||||
];
|
||||
|
||||
const recalculate = jest.spyOn(listingVarientModel, 'recalculate').mockResolvedValue();
|
||||
jest
|
||||
.spyOn(listingVarientModel, 'find')
|
||||
.mockReturnValueOnce(mockFind([]))
|
||||
.mockReturnValueOnce(mockFind(syncedVarients));
|
||||
jest.spyOn(productSkuModel, 'find').mockReturnValue(mockFind(productSkus));
|
||||
newObject.mockResolvedValue({ _id: createdVarientId });
|
||||
|
||||
await listingModel.recalculate({ _id: listingId, product: productId }, 'user-1');
|
||||
|
||||
expect(productSkuModel.find).toHaveBeenCalledWith({ product: productId });
|
||||
expect(editObject).not.toHaveBeenCalled();
|
||||
expect(deleteObject).not.toHaveBeenCalled();
|
||||
expect(newObject).toHaveBeenCalledTimes(2);
|
||||
expect(newObject).toHaveBeenCalledWith({
|
||||
model: listingVarientModel,
|
||||
newData: expect.objectContaining({
|
||||
listing: listingId,
|
||||
product: productId,
|
||||
productSku: productSkuId,
|
||||
state: { type: 'draft' },
|
||||
}),
|
||||
user: 'user-1',
|
||||
recalculate: false,
|
||||
});
|
||||
expect(recalculate).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('creates missing listing varients when one already matches a sku', async () => {
|
||||
const skuBId = new mongoose.Types.ObjectId();
|
||||
const skuCId = new mongoose.Types.ObjectId();
|
||||
const existingVarient = {
|
||||
_id: varientId,
|
||||
listing: listingId,
|
||||
product: productId,
|
||||
productSku: productSkuId,
|
||||
};
|
||||
|
||||
const recalculate = jest.spyOn(listingVarientModel, 'recalculate').mockResolvedValue();
|
||||
jest
|
||||
.spyOn(listingVarientModel, 'find')
|
||||
.mockReturnValueOnce(mockFind([existingVarient]))
|
||||
.mockReturnValueOnce(
|
||||
mockFind([
|
||||
existingVarient,
|
||||
{ _id: new mongoose.Types.ObjectId(), listing: listingId, product: productId, productSku: skuBId },
|
||||
{ _id: new mongoose.Types.ObjectId(), listing: listingId, product: productId, productSku: skuCId },
|
||||
])
|
||||
);
|
||||
jest
|
||||
.spyOn(productSkuModel, 'find')
|
||||
.mockReturnValue(mockFind([{ _id: productSkuId }, { _id: skuBId }, { _id: skuCId }]));
|
||||
newObject.mockResolvedValue({ _id: new mongoose.Types.ObjectId() });
|
||||
|
||||
await listingModel.recalculate({ _id: listingId, product: productId }, 'user-1');
|
||||
|
||||
expect(editObject).not.toHaveBeenCalled();
|
||||
expect(newObject).toHaveBeenCalledTimes(2);
|
||||
expect(newObject).toHaveBeenCalledWith({
|
||||
model: listingVarientModel,
|
||||
newData: expect.objectContaining({
|
||||
listing: listingId,
|
||||
product: productId,
|
||||
productSku: skuBId,
|
||||
state: { type: 'draft' },
|
||||
}),
|
||||
user: 'user-1',
|
||||
recalculate: false,
|
||||
});
|
||||
expect(recalculate).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('rebuilds listing varients when an existing varient is missing a product sku', async () => {
|
||||
const skuBId = new mongoose.Types.ObjectId();
|
||||
jest.spyOn(listingVarientModel, 'recalculate').mockResolvedValue();
|
||||
jest
|
||||
.spyOn(listingVarientModel, 'find')
|
||||
.mockReturnValueOnce(
|
||||
mockFind([{ _id: varientId, listing: listingId, product: productId }])
|
||||
)
|
||||
.mockReturnValueOnce(
|
||||
mockFind([
|
||||
{ _id: varientId, listing: listingId, product: productId, productSku: productSkuId },
|
||||
{
|
||||
_id: new mongoose.Types.ObjectId(),
|
||||
listing: listingId,
|
||||
product: productId,
|
||||
productSku: skuBId,
|
||||
},
|
||||
])
|
||||
);
|
||||
jest
|
||||
.spyOn(productSkuModel, 'find')
|
||||
.mockReturnValue(mockFind([{ _id: productSkuId }, { _id: skuBId }]));
|
||||
editObject.mockResolvedValue({});
|
||||
newObject.mockResolvedValue({ _id: new mongoose.Types.ObjectId() });
|
||||
|
||||
await listingModel.recalculate({ _id: listingId, product: productId }, 'user-1');
|
||||
|
||||
expect(editObject).toHaveBeenCalledWith({
|
||||
model: listingVarientModel,
|
||||
id: varientId,
|
||||
updateData: expect.objectContaining({
|
||||
product: productId,
|
||||
productSku: productSkuId,
|
||||
}),
|
||||
user: 'user-1',
|
||||
recalculate: false,
|
||||
});
|
||||
expect(newObject).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not rebuild varients when they already match the product skus', async () => {
|
||||
const existingVarient = {
|
||||
_id: varientId,
|
||||
listing: listingId,
|
||||
product: productId,
|
||||
productSku: productSkuId,
|
||||
};
|
||||
const recalculate = jest.spyOn(listingVarientModel, 'recalculate').mockResolvedValue();
|
||||
jest.spyOn(listingVarientModel, 'find').mockReturnValue(mockFind([existingVarient]));
|
||||
jest.spyOn(productSkuModel, 'find').mockReturnValue(mockFind([{ _id: productSkuId }]));
|
||||
|
||||
await listingModel.recalculate(
|
||||
{ _id: listingId, product: productId, stockLocation: stockLocationId },
|
||||
'user-1'
|
||||
);
|
||||
|
||||
expect(newObject).not.toHaveBeenCalled();
|
||||
expect(deleteObject).not.toHaveBeenCalled();
|
||||
expect(editObject).not.toHaveBeenCalled();
|
||||
expect(recalculate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('creates and updates listing varients to match product skus when a product differs', async () => {
|
||||
const otherProductId = new mongoose.Types.ObjectId();
|
||||
const skuBId = new mongoose.Types.ObjectId();
|
||||
const skuCId = new mongoose.Types.ObjectId();
|
||||
const createdVarientId = new mongoose.Types.ObjectId();
|
||||
const existingVarients = [{ _id: varientId, listing: listingId, product: otherProductId }];
|
||||
const productSkus = [{ _id: productSkuId }, { _id: skuBId }, { _id: skuCId }];
|
||||
const syncedVarients = [
|
||||
{ _id: varientId, listing: listingId, product: productId, productSku: productSkuId },
|
||||
{ _id: createdVarientId, listing: listingId, product: productId, productSku: skuBId },
|
||||
{ _id: new mongoose.Types.ObjectId(), listing: listingId, product: productId, productSku: skuCId },
|
||||
];
|
||||
|
||||
const recalculate = jest.spyOn(listingVarientModel, 'recalculate').mockResolvedValue();
|
||||
jest
|
||||
.spyOn(listingVarientModel, 'find')
|
||||
.mockReturnValueOnce(mockFind(existingVarients))
|
||||
.mockReturnValueOnce(mockFind(syncedVarients));
|
||||
jest.spyOn(productSkuModel, 'find').mockReturnValue(mockFind(productSkus));
|
||||
editObject.mockResolvedValue({});
|
||||
newObject.mockResolvedValue({ _id: createdVarientId });
|
||||
|
||||
await listingModel.recalculate({ _id: listingId, product: productId }, 'user-1');
|
||||
|
||||
expect(editObject).toHaveBeenCalledWith({
|
||||
model: listingVarientModel,
|
||||
id: varientId,
|
||||
updateData: expect.objectContaining({
|
||||
product: productId,
|
||||
productSku: productSkuId,
|
||||
}),
|
||||
user: 'user-1',
|
||||
recalculate: false,
|
||||
});
|
||||
expect(newObject).toHaveBeenCalledTimes(2);
|
||||
expect(newObject).toHaveBeenCalledWith({
|
||||
model: listingVarientModel,
|
||||
newData: expect.objectContaining({
|
||||
listing: listingId,
|
||||
product: productId,
|
||||
productSku: skuBId,
|
||||
state: { type: 'draft' },
|
||||
}),
|
||||
user: 'user-1',
|
||||
recalculate: false,
|
||||
});
|
||||
expect(deleteObject).not.toHaveBeenCalled();
|
||||
expect(recalculate).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('deletes extra listing varients when the product has fewer skus', async () => {
|
||||
const otherProductId = new mongoose.Types.ObjectId();
|
||||
const extraVarientId = new mongoose.Types.ObjectId();
|
||||
const existingVarients = [
|
||||
{ _id: varientId, listing: listingId, product: otherProductId },
|
||||
{ _id: extraVarientId, listing: listingId, product: otherProductId },
|
||||
];
|
||||
|
||||
jest.spyOn(listingVarientModel, 'recalculate').mockResolvedValue();
|
||||
jest
|
||||
.spyOn(listingVarientModel, 'find')
|
||||
.mockReturnValueOnce(mockFind(existingVarients))
|
||||
.mockReturnValueOnce(
|
||||
mockFind([{ _id: varientId, listing: listingId, product: productId, productSku: productSkuId }])
|
||||
);
|
||||
jest.spyOn(productSkuModel, 'find').mockReturnValue(mockFind([{ _id: productSkuId }]));
|
||||
editObject.mockResolvedValue({});
|
||||
deleteObject.mockResolvedValue({});
|
||||
|
||||
await listingModel.recalculate({ _id: listingId, product: productId }, 'user-1');
|
||||
|
||||
expect(editObject).toHaveBeenCalledTimes(1);
|
||||
expect(newObject).not.toHaveBeenCalled();
|
||||
expect(deleteObject).toHaveBeenCalledWith({
|
||||
model: listingVarientModel,
|
||||
id: extraVarientId,
|
||||
user: 'user-1',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('listingVarient.recalculate', () => {
|
||||
beforeEach(() => {
|
||||
aggregateRollups.mockReset();
|
||||
editObject.mockReset();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('sums sibling listing varient stock quantities onto the listing', async () => {
|
||||
aggregateRollups.mockResolvedValue({ stockQuantity: { sum: 12 } });
|
||||
editObject.mockResolvedValue({});
|
||||
|
||||
await listingVarientModel.recalculate({ listing: listingId, stockQuantity: 4 }, 'user-1');
|
||||
|
||||
expect(aggregateRollups).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
model: listingVarientModel,
|
||||
baseFilter: { listing: listingId },
|
||||
})
|
||||
);
|
||||
expect(editObject).toHaveBeenCalledWith({
|
||||
model: listingModel,
|
||||
id: listingId,
|
||||
updateData: { stockQuantity: 12 },
|
||||
user: 'user-1',
|
||||
recalculate: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('writes the product sku stock total onto the listing varient before rolling up', async () => {
|
||||
jest.spyOn(listingVarientModel, 'exists').mockResolvedValue({ _id: varientId });
|
||||
aggregateRollups.mockImplementation(async ({ model }) => {
|
||||
if (model === productStockModel) {
|
||||
return { stockQuantity: { sum: 9 } };
|
||||
}
|
||||
return { stockQuantity: { sum: 12 } };
|
||||
});
|
||||
editObject.mockResolvedValue({});
|
||||
|
||||
await listingVarientModel.recalculate(
|
||||
{
|
||||
_id: varientId,
|
||||
listing: { _id: listingId, stockLocation: stockLocationId, product: productId },
|
||||
product: productId,
|
||||
productSku: productSkuId,
|
||||
stockQuantity: 0,
|
||||
},
|
||||
'user-1'
|
||||
);
|
||||
|
||||
expect(aggregateRollups).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
model: productStockModel,
|
||||
baseFilter: {
|
||||
productSku: productSkuId,
|
||||
stockLocation: stockLocationId,
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(editObject).toHaveBeenCalledWith({
|
||||
model: listingVarientModel,
|
||||
id: varientId,
|
||||
updateData: { stockQuantity: 9 },
|
||||
user: 'user-1',
|
||||
recalculate: false,
|
||||
});
|
||||
expect(editObject).toHaveBeenCalledWith({
|
||||
model: listingModel,
|
||||
id: listingId,
|
||||
updateData: { stockQuantity: 12 },
|
||||
user: 'user-1',
|
||||
recalculate: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('productStock.recalculate', () => {
|
||||
beforeEach(() => {
|
||||
aggregateRollups.mockReset();
|
||||
editObject.mockReset();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('writes the sku/location total onto matching listing varients', async () => {
|
||||
aggregateRollups.mockResolvedValue({ stockQuantity: { sum: 9 } });
|
||||
editObject.mockResolvedValue({});
|
||||
jest.spyOn(productSkuModel, 'findById').mockReturnValue({
|
||||
select: () => ({ lean: async () => ({ product: productId }) }),
|
||||
});
|
||||
jest.spyOn(listingVarientModel, 'find').mockReturnValue({
|
||||
populate: () => ({
|
||||
lean: async () => [
|
||||
{
|
||||
_id: varientId,
|
||||
product: productId,
|
||||
productSku: productSkuId,
|
||||
stockQuantity: 0,
|
||||
listing: { product: productId, stockLocation: stockLocationId },
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
await productStockModel.recalculate(
|
||||
{ productSku: productSkuId, stockLocation: stockLocationId, currentQuantity: 9 },
|
||||
'user-1'
|
||||
);
|
||||
|
||||
expect(aggregateRollups).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
model: productStockModel,
|
||||
baseFilter: {
|
||||
productSku: productSkuId,
|
||||
stockLocation: stockLocationId,
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(editObject).toHaveBeenCalledWith({
|
||||
model: listingVarientModel,
|
||||
id: varientId,
|
||||
updateData: { stockQuantity: 9 },
|
||||
user: 'user-1',
|
||||
});
|
||||
});
|
||||
});
|
||||
251
src/database/schemas/finance/invoice.schema.js
Normal file
251
src/database/schemas/finance/invoice.schema.js
Normal file
@ -0,0 +1,251 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
const { Schema } = mongoose;
|
||||
import {
|
||||
aggregateRollups,
|
||||
aggregateRollupsHistory,
|
||||
editObject,
|
||||
getObject,
|
||||
} from '../../database.js';
|
||||
import { taxRateModel } from '../management/taxrate.schema.js';
|
||||
import { amountWithTax, resolveTaxRate } from '../../tax.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: 'paidGrandTotalAmount', property: 'grandTotalAmount', operation: 'sum' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'overdue',
|
||||
filter: { 'state.type': 'overdue' },
|
||||
rollups: [
|
||||
{ name: 'overdueCount', property: 'state.type', operation: 'count' },
|
||||
{ name: 'overdueGrandTotalAmount', property: 'grandTotalAmount', operation: 'sum' },
|
||||
],
|
||||
},
|
||||
{
|
||||
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.scheduledProperties = [
|
||||
{
|
||||
name: 'dueAt',
|
||||
filter: { state: 'due|sent|acknowledged' },
|
||||
type: 'dateTime',
|
||||
},
|
||||
];
|
||||
|
||||
invoiceSchema.statics.onSchedulerEvent = async function (invoice, property) {
|
||||
const invoiceId = invoice._id || invoice;
|
||||
if (!invoiceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (property === 'dueAt' && invoice.dueAt && invoice.dueAt < new Date()) {
|
||||
await editObject({
|
||||
model: this,
|
||||
id: invoiceId,
|
||||
updateData: {
|
||||
state: { type: 'overdue' },
|
||||
},
|
||||
user: 'system',
|
||||
recalculate: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
invoiceSchema.statics.recalculate = async function (invoice, user) {
|
||||
const invoiceId = invoice._id || invoice;
|
||||
if (!invoiceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const invoiceOrderItems = [];
|
||||
for (const item of invoice.invoiceOrderItems || []) {
|
||||
const taxRate = await resolveTaxRate(item.taxRate, getObject, taxRateModel);
|
||||
invoiceOrderItems.push({
|
||||
...item,
|
||||
invoiceAmountWithTax: amountWithTax(item.invoiceAmount, taxRate),
|
||||
});
|
||||
}
|
||||
|
||||
const invoiceShipments = [];
|
||||
for (const item of invoice.invoiceShipments || []) {
|
||||
const taxRate = await resolveTaxRate(item.taxRate, getObject, taxRateModel);
|
||||
invoiceShipments.push({
|
||||
...item,
|
||||
invoiceAmountWithTax: amountWithTax(item.invoiceAmount, taxRate),
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate totals from invoiceOrderItems
|
||||
let totalAmount = 0;
|
||||
for (const item of invoiceOrderItems) {
|
||||
totalAmount += Number.parseFloat(item.invoiceAmount) || 0;
|
||||
}
|
||||
let totalAmountWithTax = 0;
|
||||
for (const item of invoiceOrderItems) {
|
||||
totalAmountWithTax += Number.parseFloat(item.invoiceAmountWithTax) || 0;
|
||||
}
|
||||
|
||||
// Calculate shipping totals from invoiceShipments
|
||||
let shippingAmount = 0;
|
||||
for (const item of invoiceShipments) {
|
||||
shippingAmount += Number.parseFloat(item.invoiceAmount) || 0;
|
||||
}
|
||||
let shippingAmountWithTax = 0;
|
||||
for (const item of 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 = {
|
||||
invoiceOrderItems,
|
||||
invoiceShipments,
|
||||
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);
|
||||
124
src/database/schemas/finance/payment.schema.js
Normal file
124
src/database/schemas/finance/payment.schema.js
Normal file
@ -0,0 +1,124 @@
|
||||
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);
|
||||
25
src/database/schemas/finance/paymentpolicy.schema.js
Normal file
25
src/database/schemas/finance/paymentpolicy.schema.js
Normal file
@ -0,0 +1,25 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
import { marketplaceSyncMappingSchema } from '../sales/marketplaceMapping.schema.js';
|
||||
|
||||
const paymentPolicySchema = new mongoose.Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
name: { type: String, required: true },
|
||||
description: { type: String, required: false },
|
||||
immediatePay: { type: Boolean, required: false, default: true },
|
||||
paymentInstructions: { type: String, required: false },
|
||||
marketplaces: { type: [marketplaceSyncMappingSchema()], default: [] },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
paymentPolicySchema.index({ name: 'text', description: 'text', paymentInstructions: 'text' });
|
||||
|
||||
paymentPolicySchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
paymentPolicySchema.set('toJSON', { virtuals: true });
|
||||
|
||||
export const paymentPolicyModel = mongoose.model('paymentPolicy', paymentPolicySchema);
|
||||
69
src/database/schemas/finance/taxrecord.schema.js
Normal file
69
src/database/schemas/finance/taxrecord.schema.js
Normal file
@ -0,0 +1,69 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
import { aggregateRollups, aggregateRollupsHistory } from '../../database.js';
|
||||
const { Schema } = mongoose;
|
||||
|
||||
const taxRecordSchema = new Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
taxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: true },
|
||||
transactionType: {
|
||||
type: String,
|
||||
required: true,
|
||||
enum: ['purchaseOrder', 'salesOrder', 'other'],
|
||||
},
|
||||
transaction: { type: Schema.Types.ObjectId, refPath: 'transactionType', required: true },
|
||||
amount: { type: Number, required: true },
|
||||
taxAmount: { type: Number, required: true },
|
||||
transactionDate: { required: true, type: Date, default: Date.now },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
taxRecordSchema.index({ transactionType: 'text' });
|
||||
|
||||
const rollupConfigs = [
|
||||
{
|
||||
name: 'total',
|
||||
filter: {},
|
||||
rollups: [
|
||||
{ name: 'count', property: 'amount', operation: 'count' },
|
||||
{ name: 'amount', property: 'amount', operation: 'sum' },
|
||||
{ name: 'taxAmount', property: 'taxAmount', operation: 'sum' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'salesOrder',
|
||||
filter: { transactionType: 'salesOrder' },
|
||||
rollups: [{ name: 'taxAmount', property: 'taxAmount', operation: 'sum' }],
|
||||
},
|
||||
{
|
||||
name: 'purchaseOrder',
|
||||
filter: { transactionType: 'purchaseOrder' },
|
||||
rollups: [{ name: 'taxAmount', property: 'taxAmount', operation: 'sum' }],
|
||||
},
|
||||
];
|
||||
|
||||
taxRecordSchema.statics.stats = async function () {
|
||||
return aggregateRollups({
|
||||
model: this,
|
||||
rollupConfigs,
|
||||
});
|
||||
};
|
||||
|
||||
taxRecordSchema.statics.history = async function (from, to) {
|
||||
return aggregateRollupsHistory({
|
||||
model: this,
|
||||
startDate: from,
|
||||
endDate: to,
|
||||
rollupConfigs,
|
||||
});
|
||||
};
|
||||
|
||||
taxRecordSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
taxRecordSchema.set('toJSON', { virtuals: true });
|
||||
|
||||
export const taxRecordModel = mongoose.model('taxRecord', taxRecordSchema);
|
||||
136
src/database/schemas/inventory/filamentstock.schema.js
Normal file
136
src/database/schemas/inventory/filamentstock.schema.js
Normal file
@ -0,0 +1,136 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
const { Schema } = mongoose;
|
||||
import { aggregateRollups, aggregateRollupsHistory } from '../../database.js';
|
||||
import { updateDraftStockAuditCurrents } from './stockaudit.schema.js';
|
||||
|
||||
const toId = (value) => {
|
||||
if (value == null) return null;
|
||||
if (typeof value === 'object' && value._id) return String(value._id);
|
||||
return String(value);
|
||||
};
|
||||
|
||||
// Define the main filamentStock schema
|
||||
const filamentStockSchema = 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 },
|
||||
startingWeight: {
|
||||
net: { type: Number, required: true },
|
||||
gross: { type: Number, required: true },
|
||||
},
|
||||
currentWeight: {
|
||||
net: { 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 },
|
||||
filamentSku: { type: mongoose.Schema.Types.ObjectId, ref: 'filamentSku', required: true },
|
||||
stockLocation: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'stockLocation',
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
{ 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 = [
|
||||
{
|
||||
name: 'totalCurrentWeight',
|
||||
filter: {},
|
||||
rollups: [{ name: 'totalCurrentWeight', property: 'currentWeight.net', operation: 'sum' }],
|
||||
},
|
||||
{
|
||||
name: 'draft',
|
||||
filter: { 'state.type': 'draft' },
|
||||
rollups: [{ name: 'draft', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'unconsumed',
|
||||
filter: { 'state.type': 'unconsumed' },
|
||||
rollups: [{ name: 'unconsumed', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'used',
|
||||
filter: { 'state.type': 'used' },
|
||||
rollups: [{ name: 'used', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'consumed',
|
||||
filter: { 'state.type': 'consumed' },
|
||||
rollups: [{ name: 'consumed', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
];
|
||||
|
||||
filamentStockSchema.statics.stats = async function () {
|
||||
const results = await aggregateRollups({
|
||||
model: this,
|
||||
rollupConfigs: rollupConfigs,
|
||||
});
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
filamentStockSchema.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;
|
||||
};
|
||||
|
||||
filamentStockSchema.statics.recalculate = async function (filamentStock, user) {
|
||||
const itemSkuId = toId(filamentStock?.filamentSku);
|
||||
const stockLocationId = toId(filamentStock?.stockLocation);
|
||||
if (!itemSkuId || !stockLocationId) return;
|
||||
|
||||
await updateDraftStockAuditCurrents({
|
||||
itemType: 'filament',
|
||||
itemSkuId,
|
||||
stockLocationId,
|
||||
user,
|
||||
});
|
||||
|
||||
if (filamentStock.state?.type === 'draft' || filamentStock.state?.type === 'consumed') return;
|
||||
};
|
||||
|
||||
// Add virtual id getter
|
||||
filamentStockSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
// Configure JSON serialization to include virtuals
|
||||
filamentStockSchema.set('toJSON', { virtuals: true });
|
||||
|
||||
// Create and export the model
|
||||
export const filamentStockModel = mongoose.model('filamentStock', filamentStockSchema);
|
||||
319
src/database/schemas/inventory/orderitem.schema.js
Normal file
319
src/database/schemas/inventory/orderitem.schema.js
Normal file
@ -0,0 +1,319 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { purchaseOrderModel } from './purchaseorder.schema.js';
|
||||
import { salesOrderModel } from '../sales/salesorder.schema.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 { amountWithTax, resolveTaxRate } from '../../tax.js';
|
||||
const { Schema } = mongoose;
|
||||
|
||||
const skuModelsByItemType = {
|
||||
filament: filamentSkuModel,
|
||||
part: partSkuModel,
|
||||
product: productSkuModel,
|
||||
};
|
||||
|
||||
const parentModelsByItemType = {
|
||||
filament: filamentModel,
|
||||
part: partModel,
|
||||
product: productModel,
|
||||
};
|
||||
|
||||
const orderItemSchema = new Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
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 },
|
||||
itemType: { type: String, required: true },
|
||||
item: { type: Schema.Types.ObjectId, refPath: 'itemType', required: false },
|
||||
sku: {
|
||||
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 },
|
||||
quantity: { type: Number, required: true },
|
||||
totalAmount: { type: Number, required: true },
|
||||
taxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
|
||||
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 },
|
||||
shipment: { type: Schema.Types.ObjectId, ref: 'shipment', required: false },
|
||||
listing: { type: Schema.Types.ObjectId, ref: 'listing', required: false },
|
||||
listingVarient: { type: Schema.Types.ObjectId, ref: 'listingVarient', required: false },
|
||||
externalReference: { type: String, required: false },
|
||||
orderedAt: { type: Date, required: false },
|
||||
receivedAt: { type: Date, required: false },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
orderItemSchema.index({ name: 'text', itemType: 'text', orderType: 'text' });
|
||||
orderItemSchema.index({ order: 1, externalReference: 1 }, { unique: true, sparse: true });
|
||||
|
||||
const rollupConfigs = [
|
||||
{
|
||||
name: 'draft',
|
||||
filter: { 'state.type': 'draft' },
|
||||
rollups: [{ name: 'draft', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'ordered',
|
||||
filter: { 'state.type': 'ordered' },
|
||||
rollups: [{ name: 'ordered', property: 'state.type', 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' }],
|
||||
},
|
||||
{
|
||||
name: 'shippedValue',
|
||||
filter: { 'state.type': 'shipped' },
|
||||
rollups: [{ name: 'totalAmountWithTax', property: 'totalAmountWithTax', operation: 'sum' }],
|
||||
},
|
||||
{
|
||||
name: 'receivedValue',
|
||||
filter: { 'state.type': 'received' },
|
||||
rollups: [{ name: 'totalAmountWithTax', property: 'totalAmountWithTax', operation: 'sum' }],
|
||||
},
|
||||
];
|
||||
|
||||
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) {
|
||||
if (orderItem.orderType !== 'purchaseOrder' && orderItem.orderType !== 'salesOrder') {
|
||||
return;
|
||||
}
|
||||
|
||||
const orderId = orderItem.order?._id || orderItem.order;
|
||||
if (!orderId) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const taxRate = await resolveTaxRate(orderItem.taxRate, getObject, taxRateModel);
|
||||
|
||||
const orderTotalAmount = effectiveItemAmount * orderItem.quantity;
|
||||
const orderTotalAmountWithTax = amountWithTax(orderTotalAmount, taxRate);
|
||||
|
||||
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({
|
||||
model: this,
|
||||
baseFilter: {
|
||||
order: new mongoose.Types.ObjectId(orderId),
|
||||
orderType: orderItem.orderType,
|
||||
},
|
||||
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' }],
|
||||
},
|
||||
...rollupConfigs,
|
||||
],
|
||||
});
|
||||
|
||||
const totals = rollupResults.orderTotals || {};
|
||||
const totalAmount = totals.totalAmount.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({
|
||||
model: orderModel,
|
||||
id: orderId,
|
||||
updateData: updateData,
|
||||
user,
|
||||
});
|
||||
};
|
||||
|
||||
// Add virtual id getter
|
||||
orderItemSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
// Configure JSON serialization to include virtuals
|
||||
orderItemSchema.set('toJSON', { virtuals: true });
|
||||
|
||||
// Create and export the model
|
||||
export const orderItemModel = mongoose.model('orderItem', orderItemSchema);
|
||||
134
src/database/schemas/inventory/partstock.schema.js
Normal file
134
src/database/schemas/inventory/partstock.schema.js
Normal file
@ -0,0 +1,134 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
import { aggregateRollups, aggregateRollupsHistory, editObject } from '../../database.js';
|
||||
import { updateDraftStockAuditCurrents } from './stockaudit.schema.js';
|
||||
|
||||
const toId = (value) => {
|
||||
if (value == null) return null;
|
||||
if (typeof value === 'object' && value._id) return String(value._id);
|
||||
return String(value);
|
||||
};
|
||||
|
||||
// Define the main partStock schema
|
||||
const partStockSchema = new mongoose.Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
state: {
|
||||
type: { type: String, required: true, default: 'draft' },
|
||||
progress: { type: Number, required: false },
|
||||
},
|
||||
postedAt: { type: Date, required: false },
|
||||
part: { type: mongoose.Schema.Types.ObjectId, ref: 'part', required: true },
|
||||
partSku: { type: mongoose.Schema.Types.ObjectId, ref: 'partSku', 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 },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
partStockSchema.index({ 'state.type': 'text' });
|
||||
|
||||
partStockSchema.pre('validate', async function () {
|
||||
if (!this.part && this.partSku) {
|
||||
const sku = await mongoose.model('partSku').findById(this.partSku).select('part').lean();
|
||||
if (sku?.part) this.part = sku.part;
|
||||
}
|
||||
});
|
||||
|
||||
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: 'new',
|
||||
filter: { 'state.type': 'new' },
|
||||
rollups: [{ name: 'new', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'used',
|
||||
filter: { 'state.type': 'used' },
|
||||
rollups: [{ name: 'used', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'consumed',
|
||||
filter: { 'state.type': 'consumed' },
|
||||
rollups: [{ name: 'consumed', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
];
|
||||
|
||||
partStockSchema.statics.stats = async function () {
|
||||
const results = await aggregateRollups({
|
||||
model: this,
|
||||
rollupConfigs: rollupConfigs,
|
||||
});
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
partStockSchema.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;
|
||||
};
|
||||
|
||||
partStockSchema.statics.recalculate = async function (partStock, user) {
|
||||
if (!partStock?._id) return;
|
||||
|
||||
const itemSkuId = toId(partStock.partSku);
|
||||
const stockLocationId = toId(partStock.stockLocation);
|
||||
if (itemSkuId && stockLocationId) {
|
||||
await updateDraftStockAuditCurrents({
|
||||
itemType: 'part',
|
||||
itemSkuId,
|
||||
stockLocationId,
|
||||
user,
|
||||
});
|
||||
}
|
||||
|
||||
if (partStock.state?.type === 'draft' || partStock.state?.type === 'consumed') return;
|
||||
if ((Number(partStock.currentQuantity) || 0) > 0) return;
|
||||
|
||||
await editObject({
|
||||
model: this,
|
||||
id: partStock._id,
|
||||
updateData: {
|
||||
state: { ...(partStock.state || {}), type: 'consumed', progress: 0 },
|
||||
},
|
||||
user,
|
||||
recalculate: false,
|
||||
});
|
||||
};
|
||||
|
||||
// Add virtual id getter
|
||||
partStockSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
// Configure JSON serialization to include virtuals
|
||||
partStockSchema.set('toJSON', { virtuals: true });
|
||||
|
||||
// Create and export the model
|
||||
export const partStockModel = mongoose.model('partStock', partStockSchema);
|
||||
218
src/database/schemas/inventory/productstock.schema.js
Normal file
218
src/database/schemas/inventory/productstock.schema.js
Normal file
@ -0,0 +1,218 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
const { Schema } = mongoose;
|
||||
import { aggregateRollups, aggregateRollupsHistory, editObject } from '../../database.js';
|
||||
import { updateDraftStockAuditCurrents } from './stockaudit.schema.js';
|
||||
|
||||
const partStockListItemSchema = new Schema({
|
||||
part: { type: Schema.Types.ObjectId, ref: 'part', required: true },
|
||||
partSku: { type: Schema.Types.ObjectId, ref: 'partSku', required: true },
|
||||
partStocks: [{ type: Schema.Types.ObjectId, ref: 'partStock', required: false }],
|
||||
requiredQuantity: { type: Number, required: true },
|
||||
});
|
||||
|
||||
partStockListItemSchema.virtual('remainingQuantity').get(function () {
|
||||
const required = this.requiredQuantity || 0;
|
||||
const stocks = Array.isArray(this.partStocks) ? this.partStocks : [];
|
||||
const available = stocks.reduce(
|
||||
(sum, stock) => sum + (Number(stock?.currentQuantity) || 0),
|
||||
0
|
||||
);
|
||||
return required - available;
|
||||
});
|
||||
|
||||
partStockListItemSchema.set('toJSON', { virtuals: true });
|
||||
|
||||
const toId = (value) => {
|
||||
if (value == null) return null;
|
||||
if (typeof value === 'object' && value._id) return String(value._id);
|
||||
return String(value);
|
||||
};
|
||||
|
||||
// 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 },
|
||||
product: { type: mongoose.Schema.Types.ObjectId, ref: 'product', required: true },
|
||||
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 },
|
||||
},
|
||||
],
|
||||
partStockList: [partStockListItemSchema],
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
productStockSchema.index({ 'state.type': 'text' });
|
||||
|
||||
productStockSchema.pre('validate', async function () {
|
||||
if (!this.product && this.productSku) {
|
||||
const sku = await mongoose.model('productSku').findById(this.productSku).select('product').lean();
|
||||
if (sku?.product) this.product = sku.product;
|
||||
}
|
||||
if (this.partStockList?.length) {
|
||||
for (const item of this.partStockList) {
|
||||
if (!item.part && item.partSku) {
|
||||
const sku = await mongoose.model('partSku').findById(item.partSku).select('part').lean();
|
||||
if (sku?.part) item.part = sku.part;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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: 'new',
|
||||
filter: { 'state.type': 'new' },
|
||||
rollups: [{ name: 'new', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'used',
|
||||
filter: { 'state.type': 'used' },
|
||||
rollups: [{ name: 'used', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'consumed',
|
||||
filter: { 'state.type': 'consumed' },
|
||||
rollups: [{ name: 'consumed', 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;
|
||||
};
|
||||
|
||||
productStockSchema.statics.recalculate = async function (productStock, user) {
|
||||
const productSkuId = toId(productStock?.productSku);
|
||||
const stockLocationId = toId(productStock?.stockLocation);
|
||||
if (productSkuId && stockLocationId) {
|
||||
await updateDraftStockAuditCurrents({
|
||||
itemType: 'product',
|
||||
itemSkuId: productSkuId,
|
||||
stockLocationId,
|
||||
user,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
productStock?._id &&
|
||||
productStock.state?.type !== 'draft' &&
|
||||
productStock.state?.type !== 'consumed' &&
|
||||
(Number(productStock.currentQuantity) || 0) <= 0
|
||||
) {
|
||||
await editObject({
|
||||
model: this,
|
||||
id: productStock._id,
|
||||
updateData: {
|
||||
state: { ...(productStock.state || {}), type: 'consumed', progress: 0 },
|
||||
},
|
||||
user,
|
||||
recalculate: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (!productSkuId || !stockLocationId) {
|
||||
return;
|
||||
}
|
||||
|
||||
let productId = toId(productStock?.product) || toId(productStock?.productSku?.product);
|
||||
if (!productId) {
|
||||
const productSku = await mongoose.model('productSku').findById(productSkuId).select('product').lean();
|
||||
productId = toId(productSku?.product);
|
||||
}
|
||||
if (!productId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rollupResults = await aggregateRollups({
|
||||
model: this,
|
||||
baseFilter: {
|
||||
productSku: new mongoose.Types.ObjectId(productSkuId),
|
||||
stockLocation: new mongoose.Types.ObjectId(stockLocationId),
|
||||
},
|
||||
rollupConfigs: [
|
||||
{
|
||||
name: 'stockQuantity',
|
||||
rollups: [{ name: 'stockQuantity', property: 'currentQuantity', operation: 'sum' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
const stockQuantity = rollupResults.stockQuantity?.sum || 0;
|
||||
|
||||
const listingVarientModel = mongoose.model('listingVarient');
|
||||
const varients = await listingVarientModel
|
||||
.find({ productSku: productSkuId })
|
||||
.populate('listing')
|
||||
.lean();
|
||||
|
||||
for (const varient of varients) {
|
||||
const varientProductId =
|
||||
toId(varient.product) || toId(varient.listing?.product);
|
||||
const varientLocationId = toId(varient.listing?.stockLocation);
|
||||
if (varientProductId !== productId || varientLocationId !== stockLocationId) {
|
||||
continue;
|
||||
}
|
||||
if (varient.stockQuantity === stockQuantity) {
|
||||
await listingVarientModel.recalculate(varient, user);
|
||||
continue;
|
||||
}
|
||||
|
||||
await editObject({
|
||||
model: listingVarientModel,
|
||||
id: varient._id,
|
||||
updateData: { stockQuantity },
|
||||
user,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 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);
|
||||
123
src/database/schemas/inventory/purchaseorder.schema.js
Normal file
123
src/database/schemas/inventory/purchaseorder.schema.js
Normal file
@ -0,0 +1,123 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
const { Schema } = mongoose;
|
||||
import { aggregateRollups, aggregateRollupsHistory } from '../../database.js';
|
||||
|
||||
const purchaseOrderSchema = 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 },
|
||||
vendor: { type: Schema.Types.ObjectId, ref: 'vendor', required: true },
|
||||
state: {
|
||||
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 }
|
||||
);
|
||||
|
||||
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' }],
|
||||
},
|
||||
{
|
||||
name: 'awaitingReceiptValue',
|
||||
filter: {
|
||||
'state.type': {
|
||||
$in: ['sent', 'acknowledged', 'partiallyShipped', 'shipped', 'partiallyReceived'],
|
||||
},
|
||||
},
|
||||
rollups: [{ name: 'grandTotalAmount', property: 'grandTotalAmount', operation: 'sum' }],
|
||||
},
|
||||
{
|
||||
name: 'receivedValue',
|
||||
filter: { 'state.type': { $in: ['received', 'completed'] } },
|
||||
rollups: [{ name: 'grandTotalAmount', property: 'grandTotalAmount', operation: 'sum' }],
|
||||
},
|
||||
];
|
||||
|
||||
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
|
||||
purchaseOrderSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
// Configure JSON serialization to include virtuals
|
||||
purchaseOrderSchema.set('toJSON', { virtuals: true });
|
||||
|
||||
// Create and export the model
|
||||
export const purchaseOrderModel = mongoose.model('purchaseOrder', purchaseOrderSchema);
|
||||
174
src/database/schemas/inventory/shipment.schema.js
Normal file
174
src/database/schemas/inventory/shipment.schema.js
Normal file
@ -0,0 +1,174 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
const { Schema } = mongoose;
|
||||
import { purchaseOrderModel } from './purchaseorder.schema.js';
|
||||
import { salesOrderModel } from '../sales/salesorder.schema.js';
|
||||
import { taxRateModel } from '../management/taxrate.schema.js';
|
||||
import {
|
||||
aggregateRollups,
|
||||
aggregateRollupsHistory,
|
||||
editObject,
|
||||
getObject,
|
||||
} from '../../database.js';
|
||||
import { amountWithTax, resolveTaxRate } from '../../tax.js';
|
||||
|
||||
const shipmentSchema = new Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
orderType: { type: String, required: true },
|
||||
order: { type: Schema.Types.ObjectId, refPath: 'orderType', required: true },
|
||||
courierService: { type: Schema.Types.ObjectId, ref: 'courierService', required: false },
|
||||
trackingNumber: { type: String, required: false },
|
||||
externalReference: { type: String, required: false },
|
||||
amount: { type: Number, required: true },
|
||||
amountWithTax: { type: Number, required: true },
|
||||
taxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
|
||||
invoicedAmount: { type: Number, required: false, default: 0 },
|
||||
invoicedAmountWithTax: { type: Number, required: false, default: 0 },
|
||||
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: {
|
||||
type: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
shipmentSchema.index({ trackingNumber: 'text', orderType: 'text' });
|
||||
shipmentSchema.index({ order: 1, externalReference: 1 }, { unique: true, sparse: true });
|
||||
|
||||
const rollupConfigs = [
|
||||
{
|
||||
name: 'draft',
|
||||
filter: { 'state.type': 'draft' },
|
||||
rollups: [{ name: 'draft', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'planned',
|
||||
filter: { 'state.type': 'planned' },
|
||||
rollups: [{ name: 'planned', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'shipped',
|
||||
filter: { 'state.type': 'shipped' },
|
||||
rollups: [{ name: 'shipped', 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: 'inTransitValue',
|
||||
filter: { 'state.type': 'shipped' },
|
||||
rollups: [{ name: 'amountWithTax', property: 'amountWithTax', operation: 'sum' }],
|
||||
},
|
||||
];
|
||||
|
||||
shipmentSchema.statics.stats = async function () {
|
||||
return aggregateRollups({
|
||||
model: this,
|
||||
rollupConfigs,
|
||||
});
|
||||
};
|
||||
|
||||
shipmentSchema.statics.history = async function (from, to) {
|
||||
return aggregateRollupsHistory({
|
||||
model: this,
|
||||
startDate: from,
|
||||
endDate: to,
|
||||
rollupConfigs,
|
||||
});
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const taxRate = await resolveTaxRate(shipment.taxRate, getObject, taxRateModel);
|
||||
|
||||
const amountWithTaxValue = amountWithTax(shipment.amount || 0, taxRate);
|
||||
await editObject({
|
||||
model: shipmentModel,
|
||||
id: shipment._id,
|
||||
updateData: {
|
||||
amountWithTax: amountWithTaxValue,
|
||||
invoicedAmountRemaining: shipment.amount - (shipment.invoicedAmount || 0),
|
||||
invoicedAmountWithTaxRemaining:
|
||||
amountWithTaxValue - (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
|
||||
shipmentSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
// Configure JSON serialization to include virtuals
|
||||
shipmentSchema.set('toJSON', { virtuals: true });
|
||||
|
||||
// Create and export the model
|
||||
export const shipmentModel = mongoose.model('shipment', shipmentSchema);
|
||||
311
src/database/schemas/inventory/stockaudit.schema.js
Normal file
311
src/database/schemas/inventory/stockaudit.schema.js
Normal file
@ -0,0 +1,311 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
import { editObject, getObject } from '../../database.js';
|
||||
import { stockAuditLevelModel, normalizeAuditLevelLine } from '../management/stockauditlevel.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';
|
||||
|
||||
const { Schema } = mongoose;
|
||||
|
||||
const itemModelsByType = {
|
||||
filament: filamentModel,
|
||||
part: partModel,
|
||||
product: productModel,
|
||||
};
|
||||
|
||||
const skuModelsByType = {
|
||||
filament: filamentSkuModel,
|
||||
part: partSkuModel,
|
||||
product: productSkuModel,
|
||||
};
|
||||
|
||||
const parentFieldByType = {
|
||||
filament: 'filament',
|
||||
part: 'part',
|
||||
product: 'product',
|
||||
};
|
||||
|
||||
const toId = (value) => {
|
||||
if (value == null) return null;
|
||||
if (typeof value === 'object' && value._id != null) return String(value._id);
|
||||
return String(value);
|
||||
};
|
||||
|
||||
const stockAuditLineSchema = new Schema(
|
||||
{
|
||||
itemType: {
|
||||
type: String,
|
||||
enum: ['filament', 'part', 'product'],
|
||||
required: true,
|
||||
},
|
||||
item: { type: Schema.Types.ObjectId, refPath: 'auditLines.itemType', required: true },
|
||||
itemSku: {
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: function () {
|
||||
return ['filament', 'part', 'product'].includes(this.itemType)
|
||||
? this.itemType + 'Sku'
|
||||
: null;
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
current: { type: Number, required: true, default: 0 },
|
||||
actual: { type: Number, required: true, default: 0 },
|
||||
new: { type: Number, required: true, default: 0 },
|
||||
},
|
||||
{ _id: true }
|
||||
);
|
||||
|
||||
const stockAuditSchema = new Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
state: {
|
||||
type: { type: String, required: true, default: 'draft' },
|
||||
progress: { type: Number, required: false },
|
||||
},
|
||||
auditLevel: {
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: 'stockAuditLevel',
|
||||
required: true,
|
||||
},
|
||||
stockLocation: {
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: 'stockLocation',
|
||||
required: true,
|
||||
},
|
||||
postedAt: { type: Date, required: false },
|
||||
auditLines: { type: [stockAuditLineSchema], default: [] },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
stockAuditSchema.index({ 'state.type': 'text' });
|
||||
|
||||
stockAuditSchema.statics.stats = async function () {
|
||||
const [draft, complete] = await Promise.all([
|
||||
this.countDocuments({ 'state.type': 'draft' }),
|
||||
this.countDocuments({ 'state.type': 'complete' }),
|
||||
]);
|
||||
return {
|
||||
draft: { count: draft },
|
||||
complete: { count: complete },
|
||||
};
|
||||
};
|
||||
|
||||
stockAuditSchema.statics.history = async function () {
|
||||
return [];
|
||||
};
|
||||
|
||||
async function fetchItemsForLevelLine(levelLine) {
|
||||
const itemType = levelLine.itemType;
|
||||
const itemModel = itemModelsByType[itemType];
|
||||
if (!itemModel) return [];
|
||||
|
||||
if (levelLine.allItems) {
|
||||
return itemModel.find().select('_id').lean();
|
||||
}
|
||||
|
||||
const itemId = toId(levelLine.item);
|
||||
if (!itemId) return [];
|
||||
return [{ _id: itemId }];
|
||||
}
|
||||
|
||||
async function fetchSkusForLevelLine(levelLine, itemId) {
|
||||
const itemType = levelLine.itemType;
|
||||
const skuModel = skuModelsByType[itemType];
|
||||
const parentField = parentFieldByType[itemType];
|
||||
if (!skuModel || !parentField) return [];
|
||||
|
||||
if (levelLine.allSkus) {
|
||||
return skuModel.find({ [parentField]: itemId }).select('_id').lean();
|
||||
}
|
||||
|
||||
const skuId = toId(levelLine.itemSku);
|
||||
if (!skuId) return [];
|
||||
return [{ _id: skuId }];
|
||||
}
|
||||
|
||||
export async function getCurrentQuantityAtLocation(itemType, itemSkuId, stockLocationId) {
|
||||
const locationId = toId(stockLocationId);
|
||||
const skuId = toId(itemSkuId);
|
||||
if (!locationId || !skuId) return 0;
|
||||
|
||||
if (itemType === 'filament') {
|
||||
const stocks = await mongoose
|
||||
.model('filamentStock')
|
||||
.find({ filamentSku: skuId, stockLocation: locationId })
|
||||
.select('currentWeight.net')
|
||||
.lean();
|
||||
return stocks.reduce((sum, stock) => sum + (Number(stock.currentWeight?.net) || 0), 0);
|
||||
}
|
||||
|
||||
if (itemType === 'part') {
|
||||
const stocks = await mongoose
|
||||
.model('partStock')
|
||||
.find({ partSku: skuId, stockLocation: locationId })
|
||||
.select('currentQuantity')
|
||||
.lean();
|
||||
return stocks.reduce((sum, stock) => sum + (Number(stock.currentQuantity) || 0), 0);
|
||||
}
|
||||
|
||||
if (itemType === 'product') {
|
||||
const stocks = await mongoose
|
||||
.model('productStock')
|
||||
.find({ productSku: skuId, stockLocation: locationId })
|
||||
.select('currentQuantity')
|
||||
.lean();
|
||||
return stocks.reduce((sum, stock) => sum + (Number(stock.currentQuantity) || 0), 0);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function buildAuditLineQuantities(current, actual) {
|
||||
const currentVal = Number(current) || 0;
|
||||
const actualVal = Number(actual) || 0;
|
||||
return {
|
||||
current: currentVal,
|
||||
actual: actualVal,
|
||||
new: actualVal,
|
||||
};
|
||||
}
|
||||
|
||||
function getExistingActual(line) {
|
||||
if (line?.actual != null) return Number(line.actual);
|
||||
if (line?.actualQuantity != null) return Number(line.actualQuantity);
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildAuditLineKey(itemType, itemId, itemSkuId) {
|
||||
return `${itemType}:${toId(itemId)}:${toId(itemSkuId)}`;
|
||||
}
|
||||
|
||||
async function expandLevelLinesToAuditLines(levelLines, stockLocationId, existingLines = []) {
|
||||
const existingByKey = new Map();
|
||||
for (const line of existingLines) {
|
||||
existingByKey.set(
|
||||
buildAuditLineKey(line.itemType, line.item, line.itemSku),
|
||||
line
|
||||
);
|
||||
}
|
||||
|
||||
const auditLines = [];
|
||||
|
||||
for (const levelLine of levelLines || []) {
|
||||
const items = await fetchItemsForLevelLine(levelLine);
|
||||
for (const item of items) {
|
||||
const itemId = toId(item._id);
|
||||
const skus = await fetchSkusForLevelLine(levelLine, itemId);
|
||||
for (const sku of skus) {
|
||||
const itemSkuId = toId(sku._id);
|
||||
const current = await getCurrentQuantityAtLocation(
|
||||
levelLine.itemType,
|
||||
itemSkuId,
|
||||
stockLocationId
|
||||
);
|
||||
const key = buildAuditLineKey(levelLine.itemType, itemId, itemSkuId);
|
||||
const existing = existingByKey.get(key);
|
||||
const existingActual = getExistingActual(existing);
|
||||
const actual = existingActual != null ? existingActual : current;
|
||||
|
||||
auditLines.push({
|
||||
itemType: levelLine.itemType,
|
||||
item: itemId,
|
||||
itemSku: itemSkuId,
|
||||
...buildAuditLineQuantities(current, actual),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return auditLines;
|
||||
}
|
||||
|
||||
stockAuditSchema.statics.recalculate = async function (stockAudit, user) {
|
||||
if (stockAudit?.state?.type !== 'draft') return;
|
||||
|
||||
const auditLevelId = toId(stockAudit.auditLevel?._id ?? stockAudit.auditLevel);
|
||||
const stockLocationId = toId(stockAudit.stockLocation?._id ?? stockAudit.stockLocation);
|
||||
if (!auditLevelId || !stockLocationId) return;
|
||||
|
||||
const auditLevel = await getObject({
|
||||
model: stockAuditLevelModel,
|
||||
id: auditLevelId,
|
||||
populate: [
|
||||
{ path: 'auditLines.item' },
|
||||
{ path: 'auditLines.itemSku' },
|
||||
],
|
||||
});
|
||||
|
||||
if (!auditLevel || auditLevel.error) return;
|
||||
|
||||
const auditLines = await expandLevelLinesToAuditLines(
|
||||
(auditLevel.auditLines || []).map((line) => normalizeAuditLevelLine(line)),
|
||||
stockLocationId,
|
||||
stockAudit.auditLines
|
||||
);
|
||||
|
||||
await editObject({
|
||||
model: this,
|
||||
id: stockAudit._id,
|
||||
updateData: { auditLines },
|
||||
user,
|
||||
recalculate: false,
|
||||
});
|
||||
};
|
||||
|
||||
stockAuditSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
stockAuditSchema.set('toJSON', { virtuals: true });
|
||||
|
||||
export const stockAuditModel = mongoose.model('stockAudit', stockAuditSchema);
|
||||
|
||||
export async function updateDraftStockAuditCurrents({
|
||||
itemType,
|
||||
itemSkuId,
|
||||
stockLocationId,
|
||||
user,
|
||||
}) {
|
||||
const skuId = toId(itemSkuId);
|
||||
const locationId = toId(stockLocationId);
|
||||
if (!itemType || !skuId || !locationId) return;
|
||||
|
||||
const current = await getCurrentQuantityAtLocation(itemType, skuId, locationId);
|
||||
|
||||
const draftAudits = await stockAuditModel
|
||||
.find({
|
||||
'state.type': 'draft',
|
||||
stockLocation: locationId,
|
||||
})
|
||||
.lean();
|
||||
|
||||
for (const audit of draftAudits) {
|
||||
let changed = false;
|
||||
const auditLines = (audit.auditLines || []).map((line) => {
|
||||
if (line.itemType !== itemType || toId(line.itemSku) !== skuId) {
|
||||
return line;
|
||||
}
|
||||
const lineCurrent = Number(line.current) || 0;
|
||||
if (lineCurrent === current) {
|
||||
return line;
|
||||
}
|
||||
changed = true;
|
||||
return { ...line, current };
|
||||
});
|
||||
|
||||
if (!changed) continue;
|
||||
|
||||
await editObject({
|
||||
model: stockAuditModel,
|
||||
id: audit._id,
|
||||
updateData: { auditLines },
|
||||
user,
|
||||
recalculate: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
316
src/database/schemas/inventory/stockevent.schema.js
Normal file
316
src/database/schemas/inventory/stockevent.schema.js
Normal file
@ -0,0 +1,316 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
import {
|
||||
getObject,
|
||||
editObject,
|
||||
aggregateRollups,
|
||||
aggregateRollupsHistory,
|
||||
} from '../../database.js';
|
||||
const { Schema } = mongoose;
|
||||
|
||||
const parentStockModelNames = {
|
||||
filamentStock: 'filamentStock',
|
||||
partStock: 'partStock',
|
||||
productStock: 'productStock',
|
||||
};
|
||||
|
||||
const initialStockStates = {
|
||||
filamentStock: 'unconsumed',
|
||||
partStock: 'new',
|
||||
productStock: 'new',
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
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, stockEvent) => {
|
||||
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 {
|
||||
const eventValue = Number(stockEvent?.value);
|
||||
if (Number.isFinite(eventValue) && eventValue < 0) {
|
||||
updateData.currentQuantity = Math.max(
|
||||
0,
|
||||
(Number(parentStock.currentQuantity) || 0) + eventValue
|
||||
);
|
||||
} else {
|
||||
updateData.currentQuantity = events.total;
|
||||
}
|
||||
currentAmount = updateData.currentQuantity;
|
||||
}
|
||||
|
||||
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, stockEvent) => {
|
||||
if (!parentType || !parentId) return;
|
||||
|
||||
const modelName = parentStockModelNames[parentType];
|
||||
if (!modelName) return;
|
||||
|
||||
const parentModel = mongoose.model(modelName);
|
||||
const parentStock = await getObject({
|
||||
model: parentModel,
|
||||
id: parentId,
|
||||
});
|
||||
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, stockEvent)
|
||||
),
|
||||
user,
|
||||
recalculate: parentType === 'productStock' || parentType === 'partStock',
|
||||
});
|
||||
};
|
||||
|
||||
const stockEventSchema = new Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
value: { type: Number, required: true },
|
||||
unit: { type: String, required: true },
|
||||
parent: {
|
||||
type: Schema.Types.ObjectId,
|
||||
refPath: 'parentType',
|
||||
required: true,
|
||||
},
|
||||
parentType: {
|
||||
type: String,
|
||||
required: true,
|
||||
enum: ['filamentStock', 'partStock', 'productStock'], // Add other models as needed
|
||||
},
|
||||
owner: {
|
||||
type: Schema.Types.ObjectId,
|
||||
refPath: 'ownerType',
|
||||
required: true,
|
||||
},
|
||||
ownerType: {
|
||||
type: String,
|
||||
required: true,
|
||||
enum: ['user', 'subJob', 'stockAudit', 'stockTransfer', 'productStock'],
|
||||
},
|
||||
history: [
|
||||
{
|
||||
value: { type: Number, required: true },
|
||||
timestamp: { type: Date, default: Date.now },
|
||||
},
|
||||
],
|
||||
timestamp: { type: Date, default: Date.now },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
stockEventSchema.index({ parentType: 'text', ownerType: 'text', unit: 'text' });
|
||||
|
||||
const rollupConfigs = [
|
||||
{
|
||||
name: 'partStock',
|
||||
filter: { parentType: 'partStock' },
|
||||
rollups: [{ name: 'partStock', property: 'parentType', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'filamentStock',
|
||||
filter: { parentType: 'filamentStock' },
|
||||
rollups: [{ name: 'filamentStock', property: 'parentType', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'productStock',
|
||||
filter: { parentType: 'productStock' },
|
||||
rollups: [{ name: 'productStock', property: 'parentType', operation: 'count' }],
|
||||
},
|
||||
];
|
||||
|
||||
stockEventSchema.statics.stats = async function () {
|
||||
const results = await aggregateRollups({
|
||||
model: this,
|
||||
rollupConfigs: rollupConfigs,
|
||||
});
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
stockEventSchema.statics.history = async function (from, to) {
|
||||
const results = await aggregateRollupsHistory({
|
||||
model: this,
|
||||
startDate: from,
|
||||
endDate: to,
|
||||
rollupConfigs: rollupConfigs,
|
||||
});
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
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, stockEvent);
|
||||
};
|
||||
|
||||
// Add virtual id getter
|
||||
stockEventSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
// Configure JSON serialization to include virtuals
|
||||
stockEventSchema.set('toJSON', { virtuals: true });
|
||||
|
||||
// Create and export the model
|
||||
export const stockEventModel = mongoose.model('stockEvent', stockEventSchema);
|
||||
41
src/database/schemas/inventory/stocklocation.schema.js
Normal file
41
src/database/schemas/inventory/stocklocation.schema.js
Normal file
@ -0,0 +1,41 @@
|
||||
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);
|
||||
78
src/database/schemas/inventory/stocktransfer.schema.js
Normal file
78
src/database/schemas/inventory/stocktransfer.schema.js
Normal file
@ -0,0 +1,78 @@
|
||||
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 },
|
||||
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()() },
|
||||
state: {
|
||||
type: { type: String, required: true, default: 'draft' },
|
||||
progress: { type: Number, required: false },
|
||||
},
|
||||
postedAt: { type: Date, required: false },
|
||||
fromLocation: {
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: 'stockLocation',
|
||||
required: true,
|
||||
},
|
||||
toLocation: {
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: 'stockLocation',
|
||||
required: true,
|
||||
},
|
||||
lines: { type: [stockTransferLineSchema], default: [] },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
stockTransferSchema.index({ 'state.type': '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);
|
||||
24
src/database/schemas/management/apppassword.schema.js
Normal file
24
src/database/schemas/management/apppassword.schema.js
Normal file
@ -0,0 +1,24 @@
|
||||
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);
|
||||
50
src/database/schemas/management/auditlog.schema.js
Normal file
50
src/database/schemas/management/auditlog.schema.js
Normal file
@ -0,0 +1,50 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
const { Schema } = mongoose;
|
||||
|
||||
const auditLogSchema = new Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
changes: {
|
||||
old: { type: Object, required: false },
|
||||
new: { type: Object, required: false },
|
||||
},
|
||||
operation: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
parent: {
|
||||
type: Schema.Types.ObjectId,
|
||||
refPath: 'parentType',
|
||||
required: true,
|
||||
},
|
||||
parentType: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
owner: {
|
||||
type: Schema.Types.ObjectId,
|
||||
refPath: 'ownerType',
|
||||
required: true,
|
||||
},
|
||||
ownerType: {
|
||||
type: String,
|
||||
required: true,
|
||||
enum: ['user', 'printer', 'host', 'marketplace'],
|
||||
},
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
auditLogSchema.index({ operation: 'text', parentType: 'text', ownerType: 'text' });
|
||||
|
||||
// Add virtual id getter
|
||||
auditLogSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
// Configure JSON serialization to include virtuals
|
||||
auditLogSchema.set('toJSON', { virtuals: true });
|
||||
|
||||
// Create and export the model
|
||||
export const auditLogModel = mongoose.model('auditLog', auditLogSchema);
|
||||
32
src/database/schemas/management/courier.schema.js
Normal file
32
src/database/schemas/management/courier.schema.js
Normal file
@ -0,0 +1,32 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
|
||||
const courierSchema = new mongoose.Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
name: { required: true, type: String },
|
||||
website: { required: false, type: String },
|
||||
email: { required: false, type: String },
|
||||
phone: { required: false, type: String },
|
||||
contact: { required: false, type: String },
|
||||
country: { required: false, type: String },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
courierSchema.index({
|
||||
name: 'text',
|
||||
website: 'text',
|
||||
email: 'text',
|
||||
phone: 'text',
|
||||
contact: 'text',
|
||||
country: 'text',
|
||||
});
|
||||
|
||||
courierSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
courierSchema.set('toJSON', { virtuals: true });
|
||||
|
||||
export const courierModel = mongoose.model('courier', courierSchema);
|
||||
70
src/database/schemas/management/courierservice.schema.js
Normal file
70
src/database/schemas/management/courierservice.schema.js
Normal file
@ -0,0 +1,70 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
import { taxRateModel } from './taxrate.schema.js';
|
||||
import { editObject, getObject } from '../../database.js';
|
||||
import { amountWithTax, resolveTaxRate } from '../../tax.js';
|
||||
const { Schema } = mongoose;
|
||||
|
||||
const marketplaceMappingSchema = new mongoose.Schema(
|
||||
{
|
||||
marketplace: { type: Schema.Types.ObjectId, ref: 'marketplace', required: true },
|
||||
externalReference: { type: String, required: false },
|
||||
},
|
||||
{ _id: true }
|
||||
);
|
||||
|
||||
const courierServiceSchema = new mongoose.Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
name: { required: true, type: String },
|
||||
courier: { type: Schema.Types.ObjectId, ref: 'courier', required: true },
|
||||
active: { required: true, type: Boolean },
|
||||
tracked: { required: true, type: Boolean },
|
||||
deliveryTime: { required: true, type: Number },
|
||||
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 },
|
||||
marketplaces: { type: [marketplaceMappingSchema], default: [] },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
courierServiceSchema.index({ name: 'text', website: 'text' });
|
||||
|
||||
courierServiceSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
courierServiceSchema.set('toJSON', { virtuals: true });
|
||||
|
||||
courierServiceSchema.statics.recalculate = async function (courierService, user) {
|
||||
const costTaxRate = await resolveTaxRate(courierService.costTaxRate, getObject, taxRateModel);
|
||||
const updateData = {};
|
||||
|
||||
if (courierService.cost != null) {
|
||||
updateData.costWithTax = amountWithTax(courierService.cost, costTaxRate);
|
||||
}
|
||||
if (courierService.additionalCost != null) {
|
||||
updateData.additionalCostWithTax = amountWithTax(
|
||||
courierService.additionalCost,
|
||||
costTaxRate
|
||||
);
|
||||
}
|
||||
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
await editObject({
|
||||
model: this,
|
||||
id: courierService._id,
|
||||
updateData,
|
||||
user,
|
||||
recalculate: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const courierServiceModel = mongoose.model('courierService', courierServiceSchema);
|
||||
58
src/database/schemas/management/documentjob.schema.js
Normal file
58
src/database/schemas/management/documentjob.schema.js
Normal file
@ -0,0 +1,58 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
const { Schema } = mongoose;
|
||||
|
||||
const documentJobSchema = new Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
},
|
||||
objectType: { type: String, required: false },
|
||||
object: {
|
||||
type: Schema.Types.ObjectId,
|
||||
refPath: 'objectType',
|
||||
required: true,
|
||||
},
|
||||
state: {
|
||||
type: { type: String, required: true, default: 'queued' },
|
||||
progress: { type: Number, required: false },
|
||||
message: { type: String, required: false },
|
||||
},
|
||||
documentTemplate: {
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: 'documentTemplate',
|
||||
required: true,
|
||||
},
|
||||
documentPrinter: {
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: 'documentPrinter',
|
||||
required: true,
|
||||
},
|
||||
quantity: {
|
||||
type: Number,
|
||||
required: true,
|
||||
default: 1,
|
||||
min: 1,
|
||||
},
|
||||
content: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
documentJobSchema.index({ name: 'text', objectType: 'text' });
|
||||
|
||||
// Add virtual id getter
|
||||
documentJobSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
// Configure JSON serialization to include virtuals
|
||||
documentJobSchema.set('toJSON', { virtuals: true });
|
||||
|
||||
export const documentJobModel = mongoose.model('documentJob', documentJobSchema);
|
||||
59
src/database/schemas/management/documentprinter.schema.js
Normal file
59
src/database/schemas/management/documentprinter.schema.js
Normal file
@ -0,0 +1,59 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
const { Schema } = mongoose;
|
||||
|
||||
const connectionSchema = new Schema(
|
||||
{
|
||||
interface: { type: String, required: true },
|
||||
protocol: { type: String, required: true },
|
||||
host: { type: String, required: true },
|
||||
port: { type: Number, required: false },
|
||||
username: { type: String, required: false },
|
||||
password: { type: String, required: false },
|
||||
},
|
||||
{ _id: false }
|
||||
);
|
||||
|
||||
const documentPrinterSchema = new Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
},
|
||||
connection: { type: connectionSchema, required: true },
|
||||
currentDocumentSize: { type: Schema.Types.ObjectId, ref: 'documentSize', required: false },
|
||||
supportedDocumentSizes: [{ type: Schema.Types.ObjectId, ref: 'documentSize', required: false }],
|
||||
rotateOrientation: { type: Boolean, required: false, default: false },
|
||||
tags: [{ type: String }],
|
||||
online: { type: Boolean, required: true, default: false },
|
||||
active: { type: Boolean, required: true, default: true },
|
||||
state: {
|
||||
type: { type: String, required: true, default: 'offline' },
|
||||
message: { type: String, required: false },
|
||||
progress: { type: Number, required: false },
|
||||
},
|
||||
paperState: {
|
||||
type: { type: String, required: true, default: 'unknown' },
|
||||
message: { type: String, required: false },
|
||||
},
|
||||
connectedAt: { type: Date, default: null },
|
||||
host: { type: Schema.Types.ObjectId, ref: 'host', required: true },
|
||||
vendor: { type: Schema.Types.ObjectId, ref: 'vendor', required: false },
|
||||
queue: [{ type: Schema.Types.ObjectId, ref: 'documentJob', required: false }],
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
documentPrinterSchema.index({ name: 'text', tags: 'text' });
|
||||
|
||||
// Add virtual id getter
|
||||
documentPrinterSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
// Configure JSON serialization to include virtuals
|
||||
documentPrinterSchema.set('toJSON', { virtuals: true });
|
||||
|
||||
export const documentPrinterModel = mongoose.model('documentPrinter', documentPrinterSchema);
|
||||
67
src/database/schemas/management/documentsize.schema.js
Normal file
67
src/database/schemas/management/documentsize.schema.js
Normal file
@ -0,0 +1,67 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
const { Schema } = mongoose;
|
||||
|
||||
const documentSizeSchema = new Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
},
|
||||
width: {
|
||||
type: Number,
|
||||
required: true,
|
||||
default: 0,
|
||||
},
|
||||
height: {
|
||||
type: Number,
|
||||
required: true,
|
||||
default: 0,
|
||||
},
|
||||
infiniteHeight: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
default: false,
|
||||
},
|
||||
printPadding: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
default: false,
|
||||
},
|
||||
paddingLeft: {
|
||||
type: Number,
|
||||
required: true,
|
||||
default: 0,
|
||||
},
|
||||
paddingRight: {
|
||||
type: Number,
|
||||
required: true,
|
||||
default: 0,
|
||||
},
|
||||
paddingTop: {
|
||||
type: Number,
|
||||
required: true,
|
||||
default: 0,
|
||||
},
|
||||
paddingBottom: {
|
||||
type: Number,
|
||||
required: true,
|
||||
default: 0,
|
||||
},
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
documentSizeSchema.index({ name: 'text' });
|
||||
|
||||
// Add virtual id getter
|
||||
documentSizeSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
// Configure JSON serialization to include virtuals
|
||||
documentSizeSchema.set('toJSON', { virtuals: true });
|
||||
|
||||
export const documentSizeModel = mongoose.model('documentSize', documentSizeSchema);
|
||||
166
src/database/schemas/management/documenttemplate.schema.js
Normal file
166
src/database/schemas/management/documenttemplate.schema.js
Normal file
@ -0,0 +1,166 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
const { Schema } = mongoose;
|
||||
|
||||
const RENDER_DOCUMENT_TEMPLATE_CALL =
|
||||
/fc\.renderDocumentTemplate\s*\(\s*(['"])([^'"]+)\1/g;
|
||||
|
||||
function extractRenderDocumentTemplateReferences(content) {
|
||||
if (content == null || typeof content !== 'string' || content === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const references = [];
|
||||
const seen = new Set();
|
||||
const regex = new RegExp(RENDER_DOCUMENT_TEMPLATE_CALL.source, 'g');
|
||||
let match;
|
||||
while ((match = regex.exec(content)) !== null) {
|
||||
const reference = match[2]?.trim();
|
||||
if (!reference || seen.has(reference)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(reference);
|
||||
references.push(reference);
|
||||
}
|
||||
return references;
|
||||
}
|
||||
|
||||
function objectIdsFromFilterValue(value) {
|
||||
if (value == null) {
|
||||
return [];
|
||||
}
|
||||
if (value instanceof mongoose.Types.ObjectId) {
|
||||
return [value];
|
||||
}
|
||||
if (typeof value === 'string' && /^[a-f\d]{24}$/i.test(value)) {
|
||||
return [new mongoose.Types.ObjectId(value)];
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.flatMap(objectIdsFromFilterValue);
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
if (Array.isArray(value.$in)) {
|
||||
return objectIdsFromFilterValue(value.$in);
|
||||
}
|
||||
if (value._id != null) {
|
||||
return objectIdsFromFilterValue(value._id);
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
const documentTemplateSchema = new Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
},
|
||||
objectType: { type: String, required: false },
|
||||
tags: [{ type: String }],
|
||||
active: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
default: true,
|
||||
},
|
||||
global: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
default: false,
|
||||
},
|
||||
parent: {
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: 'documentTemplate',
|
||||
required: false,
|
||||
},
|
||||
documentSize: {
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: 'documentSize',
|
||||
required: true,
|
||||
},
|
||||
documentPrinters: [
|
||||
{
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: 'documentPrinter',
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
referencedTemplates: [
|
||||
{
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: 'documentTemplate',
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
content: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '<Container></Container>',
|
||||
},
|
||||
testObject: {
|
||||
type: Schema.Types.ObjectId,
|
||||
refPath: 'objectType',
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
documentTemplateSchema.index({ name: 'text', tags: 'text', objectType: 'text' });
|
||||
|
||||
// Add virtual id getter
|
||||
documentTemplateSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
// Configure JSON serialization to include virtuals
|
||||
documentTemplateSchema.set('toJSON', { virtuals: true });
|
||||
|
||||
documentTemplateSchema.statics.recalculate = async function (documentTemplate, user) {
|
||||
const documentTemplateId = documentTemplate?._id || documentTemplate;
|
||||
if (!documentTemplateId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stillExists = await this.exists({ _id: documentTemplateId });
|
||||
if (!stillExists) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { getFilter } = await import('../../../utils.js');
|
||||
const references = extractRenderDocumentTemplateReferences(documentTemplate?.content);
|
||||
const referencedTemplateIds = [];
|
||||
const seenIds = new Set();
|
||||
|
||||
for (const reference of references) {
|
||||
const filter = await getFilter({ parent: reference }, ['parent'], true, this);
|
||||
const ids = objectIdsFromFilterValue(filter.parent);
|
||||
for (const id of ids) {
|
||||
const idString = String(id);
|
||||
if (!idString || seenIds.has(idString)) {
|
||||
continue;
|
||||
}
|
||||
seenIds.add(idString);
|
||||
referencedTemplateIds.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
if (documentTemplate && typeof documentTemplate === 'object' && !documentTemplate._bsontype) {
|
||||
documentTemplate.referencedTemplates = referencedTemplateIds.map((id) => ({
|
||||
_id: String(id),
|
||||
}));
|
||||
}
|
||||
|
||||
const { editObject } = await import('../../database.js');
|
||||
await editObject({
|
||||
model: this,
|
||||
id: documentTemplateId,
|
||||
updateData: { referencedTemplates: referencedTemplateIds },
|
||||
user,
|
||||
populate: [{ path: 'referencedTemplates', strictPopulate: false }],
|
||||
recalculate: false,
|
||||
});
|
||||
};
|
||||
|
||||
export const documentTemplateModel = mongoose.model('documentTemplate', documentTemplateSchema);
|
||||
59
src/database/schemas/management/filament.schema.js
Normal file
59
src/database/schemas/management/filament.schema.js
Normal file
@ -0,0 +1,59 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
import { taxRateModel } from '../management/taxrate.schema.js';
|
||||
import { editObject, getObject } from '../../database.js';
|
||||
import { amountWithTax, resolveTaxRate } from '../../tax.js';
|
||||
const { Schema } = mongoose;
|
||||
|
||||
// Filament base - cost and tax; color and cost override at FilamentSKU
|
||||
const filamentSchema = new mongoose.Schema({
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
name: { required: true, type: String },
|
||||
vendor: { type: Schema.Types.ObjectId, ref: 'vendor', required: false },
|
||||
barcode: { required: false, type: String },
|
||||
url: { required: false, type: String },
|
||||
image: { required: false, type: Buffer },
|
||||
material: { type: Schema.Types.ObjectId, ref: 'material', required: true },
|
||||
diameter: { required: true, type: Number },
|
||||
density: { 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 () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
filamentSchema.set('toJSON', { virtuals: true });
|
||||
|
||||
filamentSchema.statics.recalculate = async function (filament, user) {
|
||||
const costTaxRate = await resolveTaxRate(filament.costTaxRate, getObject, taxRateModel);
|
||||
const taxUpdateData = {};
|
||||
|
||||
if (filament.cost != null) {
|
||||
taxUpdateData.costWithTax = amountWithTax(filament.cost, costTaxRate);
|
||||
}
|
||||
|
||||
if (Object.keys(taxUpdateData).length > 0) {
|
||||
await editObject({
|
||||
model: this,
|
||||
id: filament._id,
|
||||
updateData: taxUpdateData,
|
||||
user,
|
||||
recalculate: false,
|
||||
});
|
||||
Object.assign(filament, taxUpdateData);
|
||||
}
|
||||
|
||||
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);
|
||||
82
src/database/schemas/management/filamentsku.schema.js
Normal file
82
src/database/schemas/management/filamentsku.schema.js
Normal file
@ -0,0 +1,82 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
import { filamentModel } from './filament.schema.js';
|
||||
import { taxRateModel } from './taxrate.schema.js';
|
||||
import { editObject, getObject } from '../../database.js';
|
||||
import { amountWithTax, resolveTaxRate } from '../../tax.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 parent = await getObject({
|
||||
model: filamentModel,
|
||||
id: filamentSku.filament?._id || filamentSku.filament,
|
||||
cached: true,
|
||||
});
|
||||
|
||||
const taxUpdateData = {};
|
||||
|
||||
if (filamentSku.overrideCost) {
|
||||
const costTaxRate = await resolveTaxRate(filamentSku.costTaxRate, getObject, taxRateModel);
|
||||
if (filamentSku.cost != null) {
|
||||
taxUpdateData.costWithTax = amountWithTax(filamentSku.cost, costTaxRate);
|
||||
}
|
||||
} else if (parent?.costWithTax != null) {
|
||||
taxUpdateData.costWithTax = parent.costWithTax;
|
||||
}
|
||||
|
||||
if (Object.keys(taxUpdateData).length > 0) {
|
||||
await editObject({
|
||||
model: this,
|
||||
id: filamentSku._id,
|
||||
updateData: taxUpdateData,
|
||||
user,
|
||||
recalculate: false,
|
||||
});
|
||||
Object.assign(filamentSku, taxUpdateData);
|
||||
}
|
||||
|
||||
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);
|
||||
25
src/database/schemas/management/file.schema.js
Normal file
25
src/database/schemas/management/file.schema.js
Normal file
@ -0,0 +1,25 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
|
||||
const fileSchema = new mongoose.Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
name: { required: true, type: String },
|
||||
type: { required: true, type: String },
|
||||
extension: { required: true, type: String },
|
||||
size: { required: false, type: Number },
|
||||
metaData: { required: false, type: Object },
|
||||
hasThumbnails: { required: false, type: Boolean, default: false },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
fileSchema.index({ name: 'text', type: 'text', extension: 'text' });
|
||||
|
||||
fileSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
fileSchema.set('toJSON', { virtuals: true });
|
||||
|
||||
export const fileModel = mongoose.model('file', fileSchema);
|
||||
64
src/database/schemas/management/host.schema.js
Normal file
64
src/database/schemas/management/host.schema.js
Normal file
@ -0,0 +1,64 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
|
||||
// Define the device schema
|
||||
const deviceInfoSchema = new mongoose.Schema(
|
||||
{
|
||||
os: {
|
||||
platform: { type: String },
|
||||
type: { type: String },
|
||||
release: { type: String },
|
||||
arch: { type: String },
|
||||
hostname: { type: String },
|
||||
},
|
||||
cpu: {
|
||||
cores: { type: Number },
|
||||
model: { type: String },
|
||||
speedMHz: { type: Number },
|
||||
},
|
||||
user: {
|
||||
uid: { type: Number },
|
||||
gid: { type: Number },
|
||||
username: { type: String },
|
||||
homedir: { type: String },
|
||||
shell: { type: String },
|
||||
},
|
||||
process: {
|
||||
nodeVersion: { type: String },
|
||||
execPath: { type: String },
|
||||
},
|
||||
},
|
||||
{ _id: false }
|
||||
);
|
||||
|
||||
const hostSchema = new mongoose.Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
name: { required: true, type: String },
|
||||
tags: [{ required: false, type: String }],
|
||||
online: { required: true, type: Boolean, default: false },
|
||||
state: {
|
||||
type: { type: String, required: true, default: 'offline' },
|
||||
message: { type: String, required: false },
|
||||
percent: { type: Number, required: false },
|
||||
},
|
||||
active: { required: true, type: Boolean, default: true },
|
||||
connectedAt: { required: false, type: Date },
|
||||
authCode: { type: { required: false, type: String } },
|
||||
deviceInfo: deviceInfoSchema,
|
||||
otp: { type: { required: false, type: String } },
|
||||
otpExpiresAt: { required: false, type: Date },
|
||||
files: [{ type: mongoose.Schema.Types.ObjectId, ref: 'file' }],
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
hostSchema.index({ name: 'text', tags: 'text' });
|
||||
|
||||
hostSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
hostSchema.set('toJSON', { virtuals: true });
|
||||
|
||||
export const hostModel = mongoose.model('host', hostSchema);
|
||||
22
src/database/schemas/management/material.schema.js
Normal file
22
src/database/schemas/management/material.schema.js
Normal file
@ -0,0 +1,22 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
|
||||
const materialSchema = new mongoose.Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
name: { required: true, type: String },
|
||||
url: { required: false, type: String },
|
||||
tags: [{ type: String }],
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
materialSchema.index({ name: 'text', url: 'text', tags: 'text' });
|
||||
|
||||
materialSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
materialSchema.set('toJSON', { virtuals: true });
|
||||
|
||||
export const materialModel = mongoose.model('material', materialSchema);
|
||||
36
src/database/schemas/management/notetype.schema.js
Normal file
36
src/database/schemas/management/notetype.schema.js
Normal file
@ -0,0 +1,36 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
const { Schema } = mongoose;
|
||||
|
||||
const noteTypeSchema = new Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
active: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
noteTypeSchema.index({ name: 'text' });
|
||||
|
||||
// Add virtual id getter
|
||||
noteTypeSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
// Configure JSON serialization to include virtuals
|
||||
noteTypeSchema.set('toJSON', { virtuals: true });
|
||||
|
||||
export const noteTypeModel = mongoose.model('noteType', noteTypeSchema);
|
||||
68
src/database/schemas/management/part.schema.js
Normal file
68
src/database/schemas/management/part.schema.js
Normal file
@ -0,0 +1,68 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
import { taxRateModel } from '../management/taxrate.schema.js';
|
||||
import { editObject, getObject } from '../../database.js';
|
||||
import { amountWithTax, resolveTaxRate } from '../../tax.js';
|
||||
const { Schema } = mongoose;
|
||||
|
||||
// Define the main part schema - cost/price and tax; override at PartSku
|
||||
const partSchema = new Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
name: { type: String, required: true },
|
||||
file: { type: mongoose.SchemaTypes.ObjectId, ref: 'file', required: false },
|
||||
cost: { type: Number, required: false },
|
||||
price: { type: Number, required: false },
|
||||
priceMode: { type: String, default: 'margin' },
|
||||
margin: { type: Number, required: false },
|
||||
amount: { type: Number, required: false },
|
||||
costTaxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
|
||||
priceTaxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
|
||||
costWithTax: { type: Number, required: false },
|
||||
priceWithTax: { type: Number, required: false },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
partSchema.index({ name: 'text' });
|
||||
|
||||
// Add virtual id getter
|
||||
partSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
// Configure JSON serialization to include virtuals
|
||||
partSchema.set('toJSON', { virtuals: true });
|
||||
|
||||
partSchema.statics.recalculate = async function (part, user) {
|
||||
const costTaxRate = await resolveTaxRate(part.costTaxRate, getObject, taxRateModel);
|
||||
const priceTaxRate = await resolveTaxRate(part.priceTaxRate, getObject, taxRateModel);
|
||||
const taxUpdateData = {};
|
||||
|
||||
if (part.cost != null) {
|
||||
taxUpdateData.costWithTax = amountWithTax(part.cost, costTaxRate);
|
||||
}
|
||||
if (part.price != null) {
|
||||
taxUpdateData.priceWithTax = amountWithTax(part.price, priceTaxRate);
|
||||
}
|
||||
|
||||
if (Object.keys(taxUpdateData).length > 0) {
|
||||
await editObject({
|
||||
model: this,
|
||||
id: part._id,
|
||||
updateData: taxUpdateData,
|
||||
user,
|
||||
recalculate: false,
|
||||
});
|
||||
Object.assign(part, taxUpdateData);
|
||||
}
|
||||
|
||||
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
|
||||
export const partModel = mongoose.model('part', partSchema);
|
||||
112
src/database/schemas/management/partsku.schema.js
Normal file
112
src/database/schemas/management/partsku.schema.js
Normal file
@ -0,0 +1,112 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
import { partModel } from './part.schema.js';
|
||||
import { taxRateModel } from './taxrate.schema.js';
|
||||
import { editObject, getObject } from '../../database.js';
|
||||
import {
|
||||
amountWithTax,
|
||||
effectiveMarginPrice,
|
||||
resolveTaxRate,
|
||||
} from '../../tax.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 parent = await getObject({
|
||||
model: partModel,
|
||||
id: partSku.part?._id || partSku.part,
|
||||
cached: true,
|
||||
});
|
||||
|
||||
const taxUpdateData = {};
|
||||
|
||||
if (partSku.overrideCost) {
|
||||
const costTaxRate = await resolveTaxRate(partSku.costTaxRate, getObject, taxRateModel);
|
||||
if (partSku.cost != null) {
|
||||
taxUpdateData.costWithTax = amountWithTax(partSku.cost, costTaxRate);
|
||||
}
|
||||
} else if (parent?.costWithTax != null) {
|
||||
taxUpdateData.costWithTax = parent.costWithTax;
|
||||
}
|
||||
|
||||
if (partSku.overridePrice) {
|
||||
const priceTaxRate = await resolveTaxRate(
|
||||
partSku.priceTaxRate ?? parent?.priceTaxRate,
|
||||
getObject,
|
||||
taxRateModel
|
||||
);
|
||||
const cost = partSku.overrideCost ? partSku.cost : parent?.cost;
|
||||
const price = effectiveMarginPrice({
|
||||
priceMode: partSku.priceMode ?? parent?.priceMode,
|
||||
price: partSku.price,
|
||||
cost,
|
||||
margin: partSku.margin ?? parent?.margin,
|
||||
});
|
||||
if (price != null) {
|
||||
taxUpdateData.priceWithTax = amountWithTax(price, priceTaxRate);
|
||||
}
|
||||
} else if (parent?.priceWithTax != null) {
|
||||
taxUpdateData.priceWithTax = parent.priceWithTax;
|
||||
}
|
||||
|
||||
if (Object.keys(taxUpdateData).length > 0) {
|
||||
await editObject({
|
||||
model: this,
|
||||
id: partSku._id,
|
||||
updateData: taxUpdateData,
|
||||
user,
|
||||
recalculate: false,
|
||||
});
|
||||
Object.assign(partSku, taxUpdateData);
|
||||
}
|
||||
|
||||
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);
|
||||
62
src/database/schemas/management/permissionsetting.schema.js
Normal file
62
src/database/schemas/management/permissionsetting.schema.js
Normal file
@ -0,0 +1,62 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
import { excludeIdFromList, getPermissionSettingsId } from '../../permissions.js';
|
||||
|
||||
const { Schema } = mongoose;
|
||||
|
||||
const permissionSettingSchema = new Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
name: { type: String, required: true },
|
||||
permissions: { type: Schema.Types.Mixed, required: false, default: () => ({}) },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
permissionSettingSchema.index({ name: 'text' });
|
||||
|
||||
permissionSettingSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
permissionSettingSchema.set('toJSON', { virtuals: true });
|
||||
|
||||
const recalculateAssignees = async (model, permissionSettingId, stillExists, user) => {
|
||||
const assigned = await model.find({ permissionSettings: permissionSettingId }).lean();
|
||||
|
||||
if (!stillExists) {
|
||||
await model.updateMany(
|
||||
{ permissionSettings: permissionSettingId },
|
||||
{ $pull: { permissionSettings: permissionSettingId } }
|
||||
);
|
||||
}
|
||||
|
||||
for (const item of assigned) {
|
||||
const nextItem = stillExists
|
||||
? item
|
||||
: {
|
||||
...item,
|
||||
permissionSettings: excludeIdFromList(item.permissionSettings, permissionSettingId),
|
||||
};
|
||||
await model.recalculate(nextItem, user);
|
||||
}
|
||||
};
|
||||
|
||||
permissionSettingSchema.statics.recalculate = async function (permissionSetting, user) {
|
||||
const permissionSettingId = getPermissionSettingsId(permissionSetting);
|
||||
if (!permissionSettingId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stillExists = await this.exists({ _id: permissionSettingId });
|
||||
const userGroupModel = mongoose.model('userGroup');
|
||||
const userModel = mongoose.model('user');
|
||||
|
||||
await recalculateAssignees(userGroupModel, permissionSettingId, stillExists, user);
|
||||
await recalculateAssignees(userModel, permissionSettingId, stillExists, user);
|
||||
};
|
||||
|
||||
export const permissionSettingModel = mongoose.model(
|
||||
'permissionSetting',
|
||||
permissionSettingSchema
|
||||
);
|
||||
87
src/database/schemas/management/product.schema.js
Normal file
87
src/database/schemas/management/product.schema.js
Normal file
@ -0,0 +1,87 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
import { taxRateModel } from '../management/taxrate.schema.js';
|
||||
import { editObject, getObject } from '../../database.js';
|
||||
import { amountWithTax, resolveTaxRate } from '../../tax.js';
|
||||
const { Schema } = mongoose;
|
||||
|
||||
// Define the main product schema
|
||||
const productSchema = new Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
name: { type: String, required: true },
|
||||
productCategory: { type: Schema.Types.ObjectId, ref: 'productCategory', required: true },
|
||||
tags: [{ 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' },
|
||||
margin: { type: Number, required: false },
|
||||
amount: { type: Number, required: false },
|
||||
costTaxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
|
||||
priceTaxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
|
||||
costWithTax: { type: Number, required: false },
|
||||
priceWithTax: { type: Number, required: false },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
productSchema.index({ name: 'text', tags: 'text', version: 'text' });
|
||||
|
||||
// Add virtual id getter
|
||||
productSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
// Configure JSON serialization to include virtuals
|
||||
productSchema.set('toJSON', { virtuals: true });
|
||||
|
||||
productSchema.statics.recalculate = async function (product, user) {
|
||||
const costTaxRate = await resolveTaxRate(product.costTaxRate, getObject, taxRateModel);
|
||||
const priceTaxRate = await resolveTaxRate(product.priceTaxRate, getObject, taxRateModel);
|
||||
const taxUpdateData = {};
|
||||
|
||||
if (product.cost != null) {
|
||||
taxUpdateData.costWithTax = amountWithTax(product.cost, costTaxRate);
|
||||
}
|
||||
if (product.price != null) {
|
||||
taxUpdateData.priceWithTax = amountWithTax(product.price, priceTaxRate);
|
||||
}
|
||||
|
||||
if (Object.keys(taxUpdateData).length > 0) {
|
||||
await editObject({
|
||||
model: this,
|
||||
id: product._id,
|
||||
updateData: taxUpdateData,
|
||||
user,
|
||||
recalculate: false,
|
||||
});
|
||||
Object.assign(product, taxUpdateData);
|
||||
}
|
||||
|
||||
const productSkuModel = mongoose.model('productSku');
|
||||
const skus = await productSkuModel.find({ product: product._id }).select('_id').lean();
|
||||
for (const sku of skus) {
|
||||
await productSkuModel.recalculate(sku, 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
|
||||
export const productModel = mongoose.model('product', productSchema);
|
||||
30
src/database/schemas/management/productcategory.schema.js
Normal file
30
src/database/schemas/management/productcategory.schema.js
Normal file
@ -0,0 +1,30 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
const { Schema } = mongoose;
|
||||
|
||||
const marketplaceMappingSchema = new mongoose.Schema(
|
||||
{
|
||||
marketplace: { type: Schema.Types.ObjectId, ref: 'marketplace', required: true },
|
||||
externalReference: { type: String, required: false },
|
||||
},
|
||||
{ _id: true }
|
||||
);
|
||||
|
||||
const productCategorySchema = new mongoose.Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
name: { required: true, type: String },
|
||||
marketplaces: { type: [marketplaceMappingSchema], default: [] },
|
||||
},
|
||||
{ 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);
|
||||
119
src/database/schemas/management/productsku.schema.js
Normal file
119
src/database/schemas/management/productsku.schema.js
Normal file
@ -0,0 +1,119 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
import { productModel } from './product.schema.js';
|
||||
import { taxRateModel } from './taxrate.schema.js';
|
||||
import { editObject, getObject } from '../../database.js';
|
||||
import {
|
||||
amountWithTax,
|
||||
effectiveMarginPrice,
|
||||
resolveTaxRate,
|
||||
} from '../../tax.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: { type: [partSkuUsageSchema], default: [] },
|
||||
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 parent = await getObject({
|
||||
model: productModel,
|
||||
id: productSku.product?._id || productSku.product,
|
||||
cached: true,
|
||||
});
|
||||
|
||||
const taxUpdateData = {};
|
||||
|
||||
if (productSku.overrideCost) {
|
||||
const costTaxRate = await resolveTaxRate(productSku.costTaxRate, getObject, taxRateModel);
|
||||
if (productSku.cost != null) {
|
||||
taxUpdateData.costWithTax = amountWithTax(productSku.cost, costTaxRate);
|
||||
}
|
||||
} else if (parent?.costWithTax != null) {
|
||||
taxUpdateData.costWithTax = parent.costWithTax;
|
||||
}
|
||||
|
||||
if (productSku.overridePrice) {
|
||||
const priceTaxRate = await resolveTaxRate(
|
||||
productSku.priceTaxRate ?? parent?.priceTaxRate,
|
||||
getObject,
|
||||
taxRateModel
|
||||
);
|
||||
const cost = productSku.overrideCost ? productSku.cost : parent?.cost;
|
||||
const price = effectiveMarginPrice({
|
||||
priceMode: productSku.priceMode ?? parent?.priceMode,
|
||||
price: productSku.price,
|
||||
cost,
|
||||
margin: productSku.margin ?? parent?.margin,
|
||||
});
|
||||
if (price != null) {
|
||||
taxUpdateData.priceWithTax = amountWithTax(price, priceTaxRate);
|
||||
}
|
||||
} else if (parent?.priceWithTax != null) {
|
||||
taxUpdateData.priceWithTax = parent.priceWithTax;
|
||||
}
|
||||
|
||||
if (Object.keys(taxUpdateData).length > 0) {
|
||||
await editObject({
|
||||
model: this,
|
||||
id: productSku._id,
|
||||
updateData: taxUpdateData,
|
||||
user,
|
||||
recalculate: false,
|
||||
});
|
||||
Object.assign(productSku, taxUpdateData);
|
||||
}
|
||||
|
||||
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);
|
||||
108
src/database/schemas/management/stockauditlevel.schema.js
Normal file
108
src/database/schemas/management/stockauditlevel.schema.js
Normal file
@ -0,0 +1,108 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
import { editObject } from '../../database.js';
|
||||
|
||||
const { Schema } = mongoose;
|
||||
|
||||
const toId = (value) => {
|
||||
if (value == null) return null;
|
||||
if (typeof value === 'object' && value._id != null) return String(value._id);
|
||||
return String(value);
|
||||
};
|
||||
|
||||
export function normalizeAuditLevelLine(line) {
|
||||
const allItems = Boolean(line?.allItems);
|
||||
const allSkus = allItems ? true : Boolean(line?.allSkus);
|
||||
const item = allItems ? null : (line?.item?._id ?? line?.item ?? null);
|
||||
const itemSku = allItems || allSkus ? null : (line?.itemSku?._id ?? line?.itemSku ?? null);
|
||||
|
||||
return {
|
||||
_id: line?._id,
|
||||
itemType: line?.itemType,
|
||||
allItems,
|
||||
allSkus,
|
||||
item,
|
||||
itemSku,
|
||||
};
|
||||
}
|
||||
|
||||
function auditLevelLineChanged(before, after) {
|
||||
return (
|
||||
Boolean(before?.allItems) !== Boolean(after.allItems) ||
|
||||
Boolean(before?.allSkus) !== Boolean(after.allSkus) ||
|
||||
toId(before?.item) !== toId(after.item) ||
|
||||
toId(before?.itemSku) !== toId(after.itemSku)
|
||||
);
|
||||
}
|
||||
|
||||
const stockAuditLevelLineSchema = new Schema(
|
||||
{
|
||||
itemType: {
|
||||
type: String,
|
||||
enum: ['filament', 'part', 'product'],
|
||||
required: true,
|
||||
},
|
||||
allItems: { type: Boolean, default: false },
|
||||
item: { type: Schema.Types.ObjectId, refPath: 'auditLines.itemType', required: false },
|
||||
allSkus: { type: Boolean, default: false },
|
||||
itemSku: {
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: function () {
|
||||
return ['filament', 'part', 'product'].includes(this.itemType)
|
||||
? this.itemType + 'Sku'
|
||||
: null;
|
||||
},
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
{ _id: true }
|
||||
);
|
||||
|
||||
const stockAuditLevelSchema = new Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
name: { type: String, required: true },
|
||||
tags: [{ type: String, required: true }],
|
||||
auditLines: { type: [stockAuditLevelLineSchema], default: [] },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
stockAuditLevelSchema.index({ name: 'text', tags: 'text' });
|
||||
|
||||
stockAuditLevelSchema.statics.stats = async function () {
|
||||
const count = await this.countDocuments();
|
||||
return { total: { count } };
|
||||
};
|
||||
|
||||
stockAuditLevelSchema.statics.history = async function () {
|
||||
return [];
|
||||
};
|
||||
|
||||
stockAuditLevelSchema.statics.recalculate = async function (stockAuditLevel, user) {
|
||||
if (!stockAuditLevel?._id) return;
|
||||
|
||||
const auditLines = stockAuditLevel.auditLines || [];
|
||||
const normalizedLines = auditLines.map((line) => normalizeAuditLevelLine(line));
|
||||
const changed = auditLines.some((line, index) =>
|
||||
auditLevelLineChanged(line, normalizedLines[index])
|
||||
);
|
||||
|
||||
if (!changed) return;
|
||||
|
||||
await editObject({
|
||||
model: this,
|
||||
id: stockAuditLevel._id,
|
||||
updateData: { auditLines: normalizedLines },
|
||||
user,
|
||||
recalculate: false,
|
||||
});
|
||||
};
|
||||
|
||||
stockAuditLevelSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
stockAuditLevelSchema.set('toJSON', { virtuals: true });
|
||||
|
||||
export const stockAuditLevelModel = mongoose.model('stockAuditLevel', stockAuditLevelSchema);
|
||||
31
src/database/schemas/management/taxrate.schema.js
Normal file
31
src/database/schemas/management/taxrate.schema.js
Normal file
@ -0,0 +1,31 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
import { marketplaceSyncMappingSchema } from '../sales/marketplaceMapping.schema.js';
|
||||
|
||||
const taxRateSchema = new mongoose.Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
name: { required: true, type: String },
|
||||
rate: { required: true, type: Number },
|
||||
rateType: { required: true, type: String, enum: ['percentage', 'fixed'] },
|
||||
active: { required: true, type: Boolean, default: true },
|
||||
description: { required: false, type: String },
|
||||
country: { required: false, type: String },
|
||||
jurisdiction: { required: false, type: String },
|
||||
shippingAndHandlingTaxed: { required: false, type: Boolean, default: false },
|
||||
effectiveFrom: { required: false, type: Date },
|
||||
effectiveTo: { required: false, type: Date },
|
||||
marketplaces: { type: [marketplaceSyncMappingSchema()], default: [] },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
taxRateSchema.index({ name: 'text', description: 'text', country: 'text' });
|
||||
|
||||
taxRateSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
taxRateSchema.set('toJSON', { virtuals: true });
|
||||
|
||||
export const taxRateModel = mongoose.model('taxRate', taxRateSchema);
|
||||
64
src/database/schemas/management/user.schema.js
Normal file
64
src/database/schemas/management/user.schema.js
Normal file
@ -0,0 +1,64 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
import {
|
||||
applyPermissionSettingsList,
|
||||
resolvePermissionSettings,
|
||||
resolveReferencedDocs,
|
||||
saveUserPermissionsToRedis,
|
||||
} from '../../permissions.js';
|
||||
|
||||
const { Schema } = mongoose;
|
||||
|
||||
const userSchema = new mongoose.Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
username: { required: true, type: String },
|
||||
name: { required: true, type: String },
|
||||
firstName: { required: false, type: String },
|
||||
lastName: { required: false, type: String },
|
||||
email: { required: true, type: String },
|
||||
profileImage: { type: mongoose.SchemaTypes.ObjectId, ref: 'file', required: false },
|
||||
appPasswordHash: { type: String, required: false, select: false },
|
||||
groups: [{ type: Schema.Types.ObjectId, ref: 'userGroup', required: false }],
|
||||
permissionSettings: [
|
||||
{ type: Schema.Types.ObjectId, ref: 'permissionSetting', required: false },
|
||||
],
|
||||
permissions: { type: Schema.Types.Mixed, required: false, default: () => ({}) },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
userSchema.index({ username: 'text', name: 'text', firstName: 'text', lastName: 'text', email: 'text' });
|
||||
|
||||
userSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
userSchema.set('toJSON', { virtuals: true });
|
||||
|
||||
userSchema.statics.recalculate = async function (user, actingUser) {
|
||||
const userId = user?._id || user;
|
||||
if (!userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const groups = await resolveReferencedDocs('userGroup', user?.groups);
|
||||
const settings = await resolvePermissionSettings(user);
|
||||
const permissions = applyPermissionSettingsList([...groups, ...settings]);
|
||||
if (user && typeof user === 'object' && !user._bsontype) {
|
||||
user.permissions = permissions;
|
||||
}
|
||||
await saveUserPermissionsToRedis(userId, permissions);
|
||||
const { editObject } = await import('../../database.js');
|
||||
|
||||
await editObject({
|
||||
model: this,
|
||||
id: userId,
|
||||
updateData: { permissions },
|
||||
user: actingUser,
|
||||
populate: ['profileImage', 'permissionSettings', 'groups'],
|
||||
recalculate: false,
|
||||
});
|
||||
};
|
||||
|
||||
export const userModel = mongoose.model('user', userSchema);
|
||||
74
src/database/schemas/management/usergroup.schema.js
Normal file
74
src/database/schemas/management/usergroup.schema.js
Normal file
@ -0,0 +1,74 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
import {
|
||||
applyPermissionSettingsList,
|
||||
excludeIdFromList,
|
||||
resolvePermissionSettings,
|
||||
} from '../../permissions.js';
|
||||
|
||||
const { Schema } = mongoose;
|
||||
|
||||
const userGroupSchema = new Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
name: { type: String, required: true },
|
||||
permissionSettings: [
|
||||
{ type: Schema.Types.ObjectId, ref: 'permissionSetting', required: false },
|
||||
],
|
||||
permissions: { type: Schema.Types.Mixed, required: false, default: () => ({}) },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
userGroupSchema.index({ name: 'text' });
|
||||
|
||||
userGroupSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
userGroupSchema.set('toJSON', { virtuals: true });
|
||||
|
||||
userGroupSchema.statics.recalculate = async function (userGroup, actingUser) {
|
||||
const userGroupId = userGroup?._id || userGroup;
|
||||
if (!userGroupId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stillExists = await this.exists({ _id: userGroupId });
|
||||
if (stillExists) {
|
||||
const settings = await resolvePermissionSettings(userGroup);
|
||||
const permissions = applyPermissionSettingsList(settings);
|
||||
if (userGroup && typeof userGroup === 'object' && !userGroup._bsontype) {
|
||||
userGroup.permissions = permissions;
|
||||
}
|
||||
const { editObject } = await import('../../database.js');
|
||||
|
||||
await editObject({
|
||||
model: this,
|
||||
id: userGroupId,
|
||||
updateData: { permissions },
|
||||
user: actingUser,
|
||||
populate: ['permissionSettings'],
|
||||
recalculate: false,
|
||||
});
|
||||
}
|
||||
|
||||
const userModel = mongoose.model('user');
|
||||
const users = await userModel.find({ groups: userGroupId }).lean();
|
||||
|
||||
if (!stillExists) {
|
||||
await userModel.updateMany({ groups: userGroupId }, { $pull: { groups: userGroupId } });
|
||||
}
|
||||
|
||||
for (const assignedUser of users) {
|
||||
const nextUser = stillExists
|
||||
? assignedUser
|
||||
: {
|
||||
...assignedUser,
|
||||
groups: excludeIdFromList(assignedUser.groups, userGroupId),
|
||||
};
|
||||
await userModel.recalculate(nextUser, actingUser);
|
||||
}
|
||||
};
|
||||
|
||||
export const userGroupModel = mongoose.model('userGroup', userGroupSchema);
|
||||
37
src/database/schemas/management/vendor.schema.js
Normal file
37
src/database/schemas/management/vendor.schema.js
Normal file
@ -0,0 +1,37 @@
|
||||
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 vendorSchema = new mongoose.Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
name: { required: true, type: String },
|
||||
website: { required: false, type: String },
|
||||
email: { required: false, type: String },
|
||||
phone: { required: false, type: String },
|
||||
contact: { required: false, type: String },
|
||||
country: { required: false, type: String },
|
||||
active: { required: true, type: Boolean, default: true },
|
||||
address: { required: false, type: addressSchema },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
vendorSchema.index({ name: 'text', website: 'text', email: 'text', phone: 'text', contact: 'text', country: 'text' });
|
||||
|
||||
vendorSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
vendorSchema.set('toJSON', { virtuals: true });
|
||||
|
||||
export const vendorModel = mongoose.model('vendor', vendorSchema);
|
||||
48
src/database/schemas/misc/note.schema.js
Normal file
48
src/database/schemas/misc/note.schema.js
Normal file
@ -0,0 +1,48 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
const { Schema } = mongoose;
|
||||
|
||||
const noteSchema = new mongoose.Schema({
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
parent: {
|
||||
type: Schema.Types.ObjectId,
|
||||
refPath: 'parentType',
|
||||
required: true,
|
||||
},
|
||||
parentType: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
content: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
noteType: {
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: 'noteType',
|
||||
required: true,
|
||||
},
|
||||
createdAt: {
|
||||
type: Date,
|
||||
required: true,
|
||||
default: Date.now,
|
||||
},
|
||||
updatedAt: {
|
||||
type: Date,
|
||||
required: true,
|
||||
default: Date.now,
|
||||
},
|
||||
user: {
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: 'user',
|
||||
required: false,
|
||||
},
|
||||
});
|
||||
|
||||
noteSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
noteSchema.set('toJSON', { virtuals: true });
|
||||
|
||||
export const noteModel = mongoose.model('note', noteSchema);
|
||||
51
src/database/schemas/misc/notification.schema.js
Normal file
51
src/database/schemas/misc/notification.schema.js
Normal file
@ -0,0 +1,51 @@
|
||||
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);
|
||||
58
src/database/schemas/misc/objectview.schema.js
Normal file
58
src/database/schemas/misc/objectview.schema.js
Normal file
@ -0,0 +1,58 @@
|
||||
import mongoose from 'mongoose';
|
||||
const { Schema } = mongoose;
|
||||
|
||||
const objectViewSchema = new mongoose.Schema({
|
||||
user: {
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: 'user',
|
||||
required: true,
|
||||
},
|
||||
objectType: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
required: true,
|
||||
default: '#3498DB',
|
||||
},
|
||||
private: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
default: true,
|
||||
},
|
||||
filter: {
|
||||
type: Schema.Types.Mixed,
|
||||
default: () => ({}),
|
||||
},
|
||||
sort: {
|
||||
type: Schema.Types.Mixed,
|
||||
default: () => ({}),
|
||||
},
|
||||
viewMode: {
|
||||
type: Schema.Types.Mixed,
|
||||
default: null,
|
||||
},
|
||||
createdAt: {
|
||||
type: Date,
|
||||
required: true,
|
||||
default: Date.now,
|
||||
},
|
||||
updatedAt: {
|
||||
type: Date,
|
||||
required: true,
|
||||
default: Date.now,
|
||||
},
|
||||
});
|
||||
|
||||
objectViewSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
objectViewSchema.set('toJSON', { virtuals: true });
|
||||
|
||||
export const objectViewModel = mongoose.model('objectView', objectViewSchema);
|
||||
44
src/database/schemas/misc/usernotifier.schema.js
Normal file
44
src/database/schemas/misc/usernotifier.schema.js
Normal file
@ -0,0 +1,44 @@
|
||||
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);
|
||||
47
src/database/schemas/misc/usersettings.schema.js
Normal file
47
src/database/schemas/misc/usersettings.schema.js
Normal file
@ -0,0 +1,47 @@
|
||||
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: () => ({}),
|
||||
},
|
||||
sortSidebarVisibility: {
|
||||
type: Schema.Types.Mixed,
|
||||
default: () => ({}),
|
||||
},
|
||||
columnVisibility: { type: Schema.Types.Mixed, default: () => ({}) },
|
||||
collapseState: { type: Schema.Types.Mixed, default: () => ({}) },
|
||||
},
|
||||
pageLayout: { type: Schema.Types.Mixed, default: () => ({}) },
|
||||
appearance: { 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);
|
||||
132
src/database/schemas/models.js
Normal file
132
src/database/schemas/models.js
Normal file
@ -0,0 +1,132 @@
|
||||
import { jobModel } from './production/job.schema.js';
|
||||
import { subJobModel } from './production/subjob.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 { filamentSkuModel } from './management/filamentsku.schema.js';
|
||||
import { gcodeFileModel } from './production/gcodefile.schema.js';
|
||||
import { partModel } from './management/part.schema.js';
|
||||
import { partSkuModel } from './management/partsku.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 { materialModel } from './management/material.schema.js';
|
||||
import { filamentStockModel } from './inventory/filamentstock.schema.js';
|
||||
import { purchaseOrderModel } from './inventory/purchaseorder.schema.js';
|
||||
import { orderItemModel } from './inventory/orderitem.schema.js';
|
||||
import { stockEventModel } from './inventory/stockevent.schema.js';
|
||||
import { stockAuditModel } from './inventory/stockaudit.schema.js';
|
||||
import { stockAuditLevelModel } from './management/stockauditlevel.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 { userModel } from './management/user.schema.js';
|
||||
import { userGroupModel } from './management/usergroup.schema.js';
|
||||
import { permissionSettingModel } from './management/permissionsetting.schema.js';
|
||||
import { appPasswordModel } from './management/apppassword.schema.js';
|
||||
import { noteTypeModel } from './management/notetype.schema.js';
|
||||
import { noteModel } from './misc/note.schema.js';
|
||||
import { notificationModel } from './misc/notification.schema.js';
|
||||
import { userNotifierModel } from './misc/usernotifier.schema.js';
|
||||
import { objectViewModel } from './misc/objectview.schema.js';
|
||||
import { documentSizeModel } from './management/documentsize.schema.js';
|
||||
import { documentTemplateModel } from './management/documenttemplate.schema.js';
|
||||
import { hostModel } from './management/host.schema.js';
|
||||
import { documentPrinterModel } from './management/documentprinter.schema.js';
|
||||
import { documentJobModel } from './management/documentjob.schema.js';
|
||||
import { fileModel } from './management/file.schema.js';
|
||||
import { courierServiceModel } from './management/courierservice.schema.js';
|
||||
import { courierModel } from './management/courier.schema.js';
|
||||
import { taxRateModel } from './management/taxrate.schema.js';
|
||||
import { taxRecordModel } from './finance/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 { marketplaceEventModel } from './sales/marketplaceevent.schema.js';
|
||||
import { paymentModel } from './finance/payment.schema.js';
|
||||
import { paymentPolicyModel } from './finance/paymentpolicy.schema.js';
|
||||
import { fulfillmentPolicyModel } from './sales/fulfillmentpolicy.schema.js';
|
||||
import { returnPolicyModel } from './sales/returnpolicy.schema.js';
|
||||
|
||||
function modelEntry(getModel, type, label) {
|
||||
return {
|
||||
get model() {
|
||||
return getModel();
|
||||
},
|
||||
idField: '_id',
|
||||
type,
|
||||
referenceField: '_reference',
|
||||
label,
|
||||
};
|
||||
}
|
||||
|
||||
// Map prefixes to models and id fields.
|
||||
// Model getters are lazy so circular ESM imports (schema -> utils -> models -> schema)
|
||||
// do not read bindings that are still in the temporal dead zone.
|
||||
export const models = {
|
||||
PRN: modelEntry(() => printerModel, 'printer', 'Printer'),
|
||||
PPF: modelEntry(() => printerProfileModel, 'printerProfile', 'Printer Profile'),
|
||||
FPF: modelEntry(() => filamentProfileModel, 'filamentProfile', 'Filament Profile'),
|
||||
FIL: modelEntry(() => filamentModel, 'filament', 'Filament'),
|
||||
FSU: modelEntry(() => filamentSkuModel, 'filamentSku', 'Filament SKU'),
|
||||
GCF: modelEntry(() => gcodeFileModel, 'gcodeFile', 'G-Code File'),
|
||||
JOB: modelEntry(() => jobModel, 'job', 'Job'),
|
||||
PRT: modelEntry(() => partModel, 'part', 'Part'),
|
||||
PSU: modelEntry(() => partSkuModel, 'partSku', 'Part SKU'),
|
||||
PRD: modelEntry(() => productModel, 'product', 'Product'),
|
||||
PCG: modelEntry(() => productCategoryModel, 'productCategory', 'Product Category'),
|
||||
SKU: modelEntry(() => productSkuModel, 'productSku', 'Product SKU'),
|
||||
VEN: modelEntry(() => vendorModel, 'vendor', 'Vendor'),
|
||||
MAT: modelEntry(() => materialModel, 'material', 'Material'),
|
||||
SJB: modelEntry(() => subJobModel, 'subJob', 'Sub Job'),
|
||||
FLS: modelEntry(() => filamentStockModel, 'filamentStock', 'Filament Stock'),
|
||||
SEV: modelEntry(() => stockEventModel, 'stockEvent', 'Stock Event'),
|
||||
SAU: modelEntry(() => stockAuditModel, 'stockAudit', 'Stock Audit'),
|
||||
SAL: modelEntry(() => stockAuditLevelModel, 'stockAuditLevel', 'Stock Audit Level'),
|
||||
PTS: modelEntry(() => partStockModel, 'partStock', 'Part Stock'),
|
||||
PDS: modelEntry(() => productStockModel, 'productStock', 'Product Stock'),
|
||||
SLN: modelEntry(() => stockLocationModel, 'stockLocation', 'Stock Location'),
|
||||
STT: modelEntry(() => stockTransferModel, 'stockTransfer', 'Stock Transfer'),
|
||||
ADL: modelEntry(() => auditLogModel, 'auditLog', 'Audit Log'),
|
||||
USR: modelEntry(() => userModel, 'user', 'User'),
|
||||
UGP: modelEntry(() => userGroupModel, 'userGroup', 'User Group'),
|
||||
PMS: modelEntry(() => permissionSettingModel, 'permissionSetting', 'Permission Settings'),
|
||||
APP: modelEntry(() => appPasswordModel, 'appPassword', 'App Password'),
|
||||
NTY: modelEntry(() => noteTypeModel, 'noteType', 'Note Type'),
|
||||
NTE: modelEntry(() => noteModel, 'note', 'Note'),
|
||||
NTF: modelEntry(() => notificationModel, 'notification', 'Notification'),
|
||||
ONF: modelEntry(() => userNotifierModel, 'userNotifier', 'User Notifier'),
|
||||
OVW: modelEntry(() => objectViewModel, 'objectView', 'Object View'),
|
||||
DSZ: modelEntry(() => documentSizeModel, 'documentSize', 'Document Size'),
|
||||
DTP: modelEntry(() => documentTemplateModel, 'documentTemplate', 'Document Template'),
|
||||
DPR: modelEntry(() => documentPrinterModel, 'documentPrinter', 'Document Printer'),
|
||||
DJB: modelEntry(() => documentJobModel, 'documentJob', 'Document Job'),
|
||||
HST: modelEntry(() => hostModel, 'host', 'Host'),
|
||||
FLE: modelEntry(() => fileModel, 'file', 'File'),
|
||||
POR: modelEntry(() => purchaseOrderModel, 'purchaseOrder', 'Purchase Order'),
|
||||
ODI: modelEntry(() => orderItemModel, 'orderItem', 'Order Item'),
|
||||
COS: modelEntry(() => courierServiceModel, 'courierService', 'Courier Service'),
|
||||
COR: modelEntry(() => courierModel, 'courier', 'Courier'),
|
||||
TXR: modelEntry(() => taxRateModel, 'taxRate', 'Tax Rate'),
|
||||
TXD: modelEntry(() => taxRecordModel, 'taxRecord', 'Tax Record'),
|
||||
SHP: modelEntry(() => shipmentModel, 'shipment', 'Shipment'),
|
||||
INV: modelEntry(() => invoiceModel, 'invoice', 'Invoice'),
|
||||
CLI: modelEntry(() => clientModel, 'client', 'Client'),
|
||||
SOR: modelEntry(() => salesOrderModel, 'salesOrder', 'Sales Order'),
|
||||
MKT: modelEntry(() => marketplaceModel, 'marketplace', 'Marketplace'),
|
||||
LST: modelEntry(() => listingModel, 'listing', 'Listing'),
|
||||
LVR: modelEntry(() => listingVarientModel, 'listingVarient', 'Listing Varient'),
|
||||
MKE: modelEntry(() => marketplaceEventModel, 'marketplaceEvent', 'Marketplace Event'),
|
||||
PAY: modelEntry(() => paymentModel, 'payment', 'Payment'),
|
||||
PPL: modelEntry(() => paymentPolicyModel, 'paymentPolicy', 'Payment Policy'),
|
||||
FPL: modelEntry(() => fulfillmentPolicyModel, 'fulfillmentPolicy', 'Fulfillment Policy'),
|
||||
RPL: modelEntry(() => returnPolicyModel, 'returnPolicy', 'Return Policy'),
|
||||
};
|
||||
165
src/database/schemas/production/filamentprofile.schema.js
Normal file
165
src/database/schemas/production/filamentprofile.schema.js
Normal file
@ -0,0 +1,165 @@
|
||||
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);
|
||||
49
src/database/schemas/production/gcodefile.schema.js
Normal file
49
src/database/schemas/production/gcodefile.schema.js
Normal file
@ -0,0 +1,49 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
const { Schema } = mongoose;
|
||||
|
||||
const partSchema = new mongoose.Schema({
|
||||
part: { type: Schema.Types.ObjectId, ref: 'part', required: true },
|
||||
partSku: { type: Schema.Types.ObjectId, ref: 'partSku', required: true },
|
||||
quantity: { type: Number, required: true },
|
||||
});
|
||||
|
||||
const gcodeFileSchema = new mongoose.Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
name: { required: true, type: String },
|
||||
gcodeFileName: { required: false, type: String },
|
||||
size: { type: Number, required: false },
|
||||
filament: { type: Schema.Types.ObjectId, ref: 'filament', required: true },
|
||||
filamentSku: { type: Schema.Types.ObjectId, ref: 'filamentSku', required: true },
|
||||
parts: [partSchema],
|
||||
file: { type: mongoose.SchemaTypes.ObjectId, ref: 'file', required: false },
|
||||
cost: { type: Number, required: false },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
gcodeFileSchema.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;
|
||||
}
|
||||
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 () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
gcodeFileSchema.set('toJSON', { virtuals: true });
|
||||
|
||||
export const gcodeFileModel = mongoose.model('gcodeFile', gcodeFileSchema);
|
||||
190
src/database/schemas/production/job.schema.js
Normal file
190
src/database/schemas/production/job.schema.js
Normal file
@ -0,0 +1,190 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
const { Schema } = mongoose;
|
||||
import {
|
||||
aggregateRollups,
|
||||
aggregateRollupsHistory,
|
||||
listObjects,
|
||||
editObject,
|
||||
newObject,
|
||||
deleteObject,
|
||||
} from '../../database.js';
|
||||
import { subJobModel } from './subjob.schema.js';
|
||||
|
||||
const jobSchema = new mongoose.Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
state: {
|
||||
type: { required: true, type: String },
|
||||
progress: { type: Number, required: false },
|
||||
},
|
||||
printers: [{ type: Schema.Types.ObjectId, ref: 'printer', required: false }],
|
||||
createdAt: { required: true, type: Date },
|
||||
updatedAt: { required: true, type: Date },
|
||||
startedAt: { required: false, type: Date, default: null },
|
||||
finishedAt: { required: false, type: Date, default: null },
|
||||
gcodeFile: {
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: 'gcodeFile',
|
||||
required: false,
|
||||
},
|
||||
quantity: {
|
||||
type: Number,
|
||||
required: true,
|
||||
default: 1,
|
||||
min: 1,
|
||||
},
|
||||
subJobs: [{ type: Schema.Types.ObjectId, ref: 'subJob', required: false }],
|
||||
notes: [{ type: Schema.Types.ObjectId, ref: 'note', required: false }],
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
jobSchema.index({ 'state.type': 'text' });
|
||||
|
||||
const rollupConfigs = [
|
||||
{
|
||||
name: 'queued',
|
||||
filter: { 'state.type': 'queued' },
|
||||
rollups: [{ name: 'queued', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'printing',
|
||||
filter: { 'state.type': 'printing' },
|
||||
rollups: [{ name: 'printing', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'draft',
|
||||
filter: { 'state.type': 'draft' },
|
||||
rollups: [{ name: 'draft', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'complete',
|
||||
filter: { 'state.type': 'complete' },
|
||||
rollups: [{ name: 'complete', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'failed',
|
||||
filter: { 'state.type': 'failed' },
|
||||
rollups: [{ name: 'failed', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
];
|
||||
|
||||
jobSchema.statics.stats = async function () {
|
||||
const results = await aggregateRollups({
|
||||
model: this,
|
||||
rollupConfigs: rollupConfigs,
|
||||
});
|
||||
|
||||
// Transform the results to match the expected format
|
||||
return results;
|
||||
};
|
||||
|
||||
jobSchema.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;
|
||||
};
|
||||
|
||||
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 () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
jobSchema.set('toJSON', { virtuals: true });
|
||||
|
||||
export const jobModel = mongoose.model('job', jobSchema);
|
||||
158
src/database/schemas/production/printer.schema.js
Normal file
158
src/database/schemas/production/printer.schema.js
Normal file
@ -0,0 +1,158 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
const { Schema } = mongoose;
|
||||
import { aggregateRollups, aggregateRollupsHistory, editObject } from '../../database.js';
|
||||
|
||||
// Define the moonraker connection schema
|
||||
const moonrakerSchema = new Schema(
|
||||
{
|
||||
host: { type: String, required: true },
|
||||
port: { type: Number, required: true },
|
||||
protocol: { type: String, required: true },
|
||||
apiKey: { type: String, default: null, required: false },
|
||||
},
|
||||
{ _id: false }
|
||||
);
|
||||
|
||||
const alertSchema = new Schema(
|
||||
{
|
||||
type: { type: String, required: true }, // error, info, message
|
||||
message: { type: String, required: false },
|
||||
actions: [{ type: String, required: false, default: [] }],
|
||||
_id: { type: String, required: true, default: () => generateId()() },
|
||||
code: { type: String, required: false },
|
||||
canDismiss: { type: Boolean, required: true, default: true },
|
||||
},
|
||||
{ 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
|
||||
const printerSchema = new Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
name: { type: String, required: true },
|
||||
online: { type: Boolean, required: true, default: false },
|
||||
active: { type: Boolean, required: true, default: true },
|
||||
state: {
|
||||
type: { type: String, required: true, default: 'offline' },
|
||||
message: { type: String, required: false },
|
||||
progress: { type: Number, required: false },
|
||||
},
|
||||
connectedAt: { type: Date, default: null },
|
||||
loadedFilament: {
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: 'filament',
|
||||
default: null,
|
||||
},
|
||||
moonraker: { type: moonrakerSchema, required: true },
|
||||
tags: [{ type: String }],
|
||||
firmware: { type: String },
|
||||
currentJob: { type: Schema.Types.ObjectId, ref: 'job' },
|
||||
currentSubJob: { type: Schema.Types.ObjectId, ref: 'subJob' },
|
||||
currentFilamentStock: { type: Schema.Types.ObjectId, ref: 'filamentStock' },
|
||||
queue: [{ type: Schema.Types.ObjectId, ref: 'subJob' }],
|
||||
vendor: { type: Schema.Types.ObjectId, ref: 'vendor', default: null },
|
||||
host: { type: Schema.Types.ObjectId, ref: 'host', default: null },
|
||||
alerts: [alertSchema],
|
||||
pendingSlicerUploads: { type: [pendingSlicerUploadSchema], default: [] },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
printerSchema.index({ name: 'text', tags: 'text', firmware: 'text' });
|
||||
|
||||
const rollupConfigs = [
|
||||
{
|
||||
name: 'standby',
|
||||
filter: { 'state.type': 'standby' },
|
||||
rollups: [{ name: 'standby', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'complete',
|
||||
filter: { 'state.type': 'complete' },
|
||||
rollups: [{ name: 'complete', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'printing',
|
||||
filter: { 'state.type': 'printing' },
|
||||
rollups: [{ name: 'printing', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'error',
|
||||
filter: { 'state.type': 'error' },
|
||||
rollups: [{ name: 'error', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'offline',
|
||||
filter: { 'state.type': 'offline' },
|
||||
rollups: [{ name: 'offline', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
];
|
||||
|
||||
printerSchema.statics.stats = async function () {
|
||||
const results = await aggregateRollups({
|
||||
model: this,
|
||||
baseFilter: { active: true },
|
||||
rollupConfigs: rollupConfigs,
|
||||
});
|
||||
|
||||
// Transform the results to match the expected format
|
||||
return results;
|
||||
};
|
||||
|
||||
printerSchema.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;
|
||||
};
|
||||
|
||||
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
|
||||
printerSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
// Configure JSON serialization to include virtuals
|
||||
printerSchema.set('toJSON', { virtuals: true });
|
||||
|
||||
// Create and export the model
|
||||
export const printerModel = mongoose.model('printer', printerSchema);
|
||||
111
src/database/schemas/production/printerprofile.schema.js
Normal file
111
src/database/schemas/production/printerprofile.schema.js
Normal file
@ -0,0 +1,111 @@
|
||||
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);
|
||||
54
src/database/schemas/production/subjob.schema.js
Normal file
54
src/database/schemas/production/subjob.schema.js
Normal file
@ -0,0 +1,54 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
const { Schema } = mongoose;
|
||||
|
||||
const subJobSchema = new mongoose.Schema({
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
printer: {
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: 'printer',
|
||||
required: true,
|
||||
},
|
||||
job: {
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: 'job',
|
||||
required: true,
|
||||
},
|
||||
moonrakerJobId: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
gcodeFile: {
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: 'gcodeFile',
|
||||
required: true,
|
||||
},
|
||||
state: {
|
||||
type: { required: true, type: String },
|
||||
progress: { required: false, type: Number },
|
||||
},
|
||||
number: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
createdAt: {
|
||||
type: Date,
|
||||
default: Date.now,
|
||||
},
|
||||
updatedAt: {
|
||||
type: Date,
|
||||
default: Date.now,
|
||||
},
|
||||
startedAt: { 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 () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
subJobSchema.set('toJSON', { virtuals: true });
|
||||
|
||||
export const subJobModel = mongoose.model('subJob', subJobSchema);
|
||||
73
src/database/schemas/sales/client.schema.js
Normal file
73
src/database/schemas/sales/client.schema.js
Normal file
@ -0,0 +1,73 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
import { aggregateRollups, aggregateRollupsHistory } from '../../database.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 },
|
||||
externalReference: { type: String, 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.index({ marketplace: 1, externalReference: 1 }, { unique: true, sparse: true });
|
||||
|
||||
clientSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
clientSchema.set('toJSON', { virtuals: true });
|
||||
|
||||
const rollupConfigs = [
|
||||
{
|
||||
name: 'active',
|
||||
filter: { active: true },
|
||||
rollups: [{ name: 'active', property: 'active', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'inactive',
|
||||
filter: { active: false },
|
||||
rollups: [{ name: 'inactive', property: 'active', operation: 'count' }],
|
||||
},
|
||||
];
|
||||
|
||||
clientSchema.statics.stats = async function () {
|
||||
const results = await aggregateRollups({
|
||||
model: this,
|
||||
rollupConfigs: rollupConfigs,
|
||||
});
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
clientSchema.statics.history = async function (from, to) {
|
||||
const results = await aggregateRollupsHistory({
|
||||
model: this,
|
||||
startDate: from,
|
||||
endDate: to,
|
||||
rollupConfigs: rollupConfigs,
|
||||
});
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
export const clientModel = mongoose.model('client', clientSchema);
|
||||
31
src/database/schemas/sales/fulfillmentpolicy.schema.js
Normal file
31
src/database/schemas/sales/fulfillmentpolicy.schema.js
Normal file
@ -0,0 +1,31 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
import { marketplaceSyncMappingSchema } from './marketplaceMapping.schema.js';
|
||||
|
||||
const { Schema } = mongoose;
|
||||
|
||||
const fulfillmentPolicySchema = new Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
name: { type: String, required: true },
|
||||
description: { type: String, required: false },
|
||||
handlingTime: { type: Number, required: false, default: 1 },
|
||||
localPickup: { type: Boolean, required: false, default: false },
|
||||
globalShipping: { type: Boolean, required: false, default: false },
|
||||
freightShipping: { type: Boolean, required: false, default: false },
|
||||
pickupDropOff: { type: Boolean, required: false, default: false },
|
||||
courierServices: [{ type: Schema.Types.ObjectId, ref: 'courierService', required: false }],
|
||||
marketplaces: { type: [marketplaceSyncMappingSchema()], default: [] },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
fulfillmentPolicySchema.index({ name: 'text', description: 'text' });
|
||||
|
||||
fulfillmentPolicySchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
fulfillmentPolicySchema.set('toJSON', { virtuals: true });
|
||||
|
||||
export const fulfillmentPolicyModel = mongoose.model('fulfillmentPolicy', fulfillmentPolicySchema);
|
||||
260
src/database/schemas/sales/listing.schema.js
Normal file
260
src/database/schemas/sales/listing.schema.js
Normal file
@ -0,0 +1,260 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
import {
|
||||
editObject,
|
||||
newObject,
|
||||
deleteObject,
|
||||
aggregateRollups,
|
||||
aggregateRollupsHistory,
|
||||
} from '../../database.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',
|
||||
'publishing',
|
||||
'unpublishing',
|
||||
],
|
||||
default: 'draft',
|
||||
},
|
||||
progress: { type: Number, required: false },
|
||||
message: { type: String, required: false },
|
||||
},
|
||||
url: { type: String, required: false },
|
||||
description: { type: String, required: false },
|
||||
listingImages: [{ type: Schema.Types.ObjectId, ref: 'file', required: false }],
|
||||
externalReference: { type: String, required: false },
|
||||
price: { type: Number, required: false },
|
||||
currency: { type: String, required: false },
|
||||
lastSyncedAt: { type: Date, required: false },
|
||||
syncHash: { type: String, required: false },
|
||||
syncImageHash: { type: String, required: false },
|
||||
marketplaceImageUrls: [{ type: String, required: false }],
|
||||
stockQuantity: { type: Number, required: false, default: 0 },
|
||||
condition: {
|
||||
type: String,
|
||||
enum: [
|
||||
'new',
|
||||
'likeNew',
|
||||
'newOther',
|
||||
'newWithDefects',
|
||||
'manufacturerRefurbished',
|
||||
'certifiedRefurbished',
|
||||
'excellentRefurbished',
|
||||
'veryGoodRefurbished',
|
||||
'goodRefurbished',
|
||||
'sellerRefurbished',
|
||||
'usedExcellent',
|
||||
'usedVeryGood',
|
||||
'usedGood',
|
||||
'usedAcceptable',
|
||||
'forPartsOrNotWorking',
|
||||
'preOwnedExcellent',
|
||||
'preOwnedFair',
|
||||
],
|
||||
default: 'new',
|
||||
required: false,
|
||||
},
|
||||
courierServices: [{ type: Schema.Types.ObjectId, ref: 'courierService', required: true }],
|
||||
fulfillmentPolicy: { type: Schema.Types.ObjectId, ref: 'fulfillmentPolicy', required: false },
|
||||
paymentPolicy: { type: Schema.Types.ObjectId, ref: 'paymentPolicy', required: false },
|
||||
returnPolicy: { type: Schema.Types.ObjectId, ref: 'returnPolicy', required: false },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
listingSchema.index({ title: 'text', url: 'text' });
|
||||
listingSchema.index({ marketplace: 1, externalReference: 1 }, { unique: true, sparse: true });
|
||||
|
||||
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;
|
||||
},
|
||||
});
|
||||
|
||||
const refId = (value) => value?._id ?? value;
|
||||
|
||||
listingSchema.statics.recalculate = async function (listing, user) {
|
||||
const listingId = refId(listing);
|
||||
if (!listingId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const listingVarientModel = mongoose.model('listingVarient');
|
||||
const productSkuModel = mongoose.model('productSku');
|
||||
const listingProductId = refId(listing.product);
|
||||
|
||||
const findVarients = () =>
|
||||
listingVarientModel.find({ listing: listingId }).sort({ createdAt: 1 }).lean();
|
||||
|
||||
let varients = await findVarients();
|
||||
|
||||
if (listingProductId) {
|
||||
const productSkus = await productSkuModel
|
||||
.find({ product: listingProductId })
|
||||
.sort({ createdAt: 1 })
|
||||
.lean();
|
||||
|
||||
const varientsBySkuId = new Map();
|
||||
const unmatchedVarients = [];
|
||||
for (const varient of varients) {
|
||||
const skuId = refId(varient.productSku);
|
||||
if (skuId && !varientsBySkuId.has(String(skuId))) {
|
||||
varientsBySkuId.set(String(skuId), varient);
|
||||
} else {
|
||||
unmatchedVarients.push(varient);
|
||||
}
|
||||
}
|
||||
|
||||
for (const sku of productSkus) {
|
||||
const skuId = sku._id;
|
||||
const varientUpdateData = {
|
||||
product: listingProductId,
|
||||
productSku: skuId,
|
||||
};
|
||||
const existingVarient = varientsBySkuId.get(String(skuId)) || unmatchedVarients.shift();
|
||||
|
||||
if (existingVarient) {
|
||||
varientsBySkuId.delete(String(skuId));
|
||||
const existingProductId = refId(existingVarient.product);
|
||||
const existingSkuId = refId(existingVarient.productSku);
|
||||
if (
|
||||
String(existingProductId) === String(listingProductId) &&
|
||||
String(existingSkuId) === String(skuId)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const varientResult = await editObject({
|
||||
model: listingVarientModel,
|
||||
id: existingVarient._id,
|
||||
updateData: varientUpdateData,
|
||||
user,
|
||||
recalculate: false,
|
||||
});
|
||||
if (varientResult.error) {
|
||||
throw varientResult;
|
||||
}
|
||||
} else {
|
||||
const varientResult = await newObject({
|
||||
model: listingVarientModel,
|
||||
newData: {
|
||||
...varientUpdateData,
|
||||
listing: listingId,
|
||||
state: { type: listing.state?.type || 'draft' },
|
||||
},
|
||||
user,
|
||||
recalculate: false,
|
||||
});
|
||||
if (varientResult.error) {
|
||||
throw varientResult;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const extra of [...varientsBySkuId.values(), ...unmatchedVarients]) {
|
||||
const deleteResult = await deleteObject({
|
||||
model: listingVarientModel,
|
||||
id: extra._id,
|
||||
user,
|
||||
});
|
||||
if (deleteResult.error) {
|
||||
throw deleteResult;
|
||||
}
|
||||
}
|
||||
|
||||
varients = await findVarients();
|
||||
}
|
||||
|
||||
for (const varient of varients) {
|
||||
await listingVarientModel.recalculate(varient, user);
|
||||
}
|
||||
};
|
||||
|
||||
const rollupConfigs = [
|
||||
{
|
||||
name: 'draft',
|
||||
filter: { 'state.type': 'draft' },
|
||||
rollups: [{ name: 'draft', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'active',
|
||||
filter: { 'state.type': 'active' },
|
||||
rollups: [{ name: 'active', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'inactive',
|
||||
filter: { 'state.type': 'inactive' },
|
||||
rollups: [{ name: 'inactive', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'syncing',
|
||||
filter: { 'state.type': 'syncing' },
|
||||
rollups: [{ name: 'syncing', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'publishing',
|
||||
filter: { 'state.type': 'publishing' },
|
||||
rollups: [{ name: 'publishing', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'unpublishing',
|
||||
filter: { 'state.type': 'unpublishing' },
|
||||
rollups: [{ name: 'unpublishing', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'suspended',
|
||||
filter: { 'state.type': 'suspended' },
|
||||
rollups: [{ name: 'suspended', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'deleted',
|
||||
filter: { 'state.type': 'deleted' },
|
||||
rollups: [{ name: 'deleted', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
];
|
||||
|
||||
listingSchema.statics.stats = async function () {
|
||||
const results = await aggregateRollups({
|
||||
model: this,
|
||||
rollupConfigs: rollupConfigs,
|
||||
});
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
listingSchema.statics.history = async function (from, to) {
|
||||
const results = await aggregateRollupsHistory({
|
||||
model: this,
|
||||
startDate: from,
|
||||
endDate: to,
|
||||
rollupConfigs: rollupConfigs,
|
||||
});
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
export const listingModel = mongoose.model('listing', listingSchema);
|
||||
171
src/database/schemas/sales/listingvarient.schema.js
Normal file
171
src/database/schemas/sales/listingvarient.schema.js
Normal file
@ -0,0 +1,171 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
import { aggregateRollups, editObject } from '../../database.js';
|
||||
const { Schema } = mongoose;
|
||||
|
||||
const toId = (value) => {
|
||||
if (value == null) return null;
|
||||
if (typeof value === 'object' && value._id) return String(value._id);
|
||||
return String(value);
|
||||
};
|
||||
|
||||
const listingVarientAspectSchema = new Schema(
|
||||
{
|
||||
name: { type: String, required: true },
|
||||
value: { type: String, required: true },
|
||||
},
|
||||
{ _id: true }
|
||||
);
|
||||
|
||||
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 },
|
||||
aspects: { type: [listingVarientAspectSchema], default: [] },
|
||||
state: {
|
||||
type: {
|
||||
type: String,
|
||||
enum: [
|
||||
'draft',
|
||||
'active',
|
||||
'inactive',
|
||||
'deleted',
|
||||
'suspended',
|
||||
'syncing',
|
||||
'publishing',
|
||||
'unpublishing',
|
||||
],
|
||||
default: 'draft',
|
||||
},
|
||||
message: { type: String, required: false },
|
||||
},
|
||||
externalReference: { 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 },
|
||||
syncHash: { type: String, required: false },
|
||||
syncImageHash: { type: String, required: false },
|
||||
marketplaceImageUrls: [{ type: String, required: false }],
|
||||
listingImages: [{ type: Schema.Types.ObjectId, ref: 'file', required: false }],
|
||||
stockQuantity: { type: Number, required: false, default: 0 },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
listingVarientSchema.index({ currency: 'text', 'state.type': 'text' });
|
||||
listingVarientSchema.index(
|
||||
{ listing: 1, externalReference: 1 },
|
||||
{
|
||||
unique: true,
|
||||
name: 'listing_1_externalReference_1',
|
||||
partialFilterExpression: { externalReference: { $type: 'string', $gt: '' } },
|
||||
}
|
||||
);
|
||||
|
||||
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;
|
||||
},
|
||||
});
|
||||
|
||||
listingVarientSchema.statics.recalculate = async function (listingVarient, user) {
|
||||
const listingId = listingVarient?.listing?._id || listingVarient?.listing;
|
||||
const varientId = listingVarient?._id;
|
||||
const productSkuId = toId(listingVarient?.productSku);
|
||||
|
||||
if (varientId && productSkuId && (await this.exists({ _id: varientId }))) {
|
||||
let listing = listingVarient.listing;
|
||||
if (!listing?.stockLocation) {
|
||||
listing = await mongoose
|
||||
.model('listing')
|
||||
.findById(listingId)
|
||||
.select('stockLocation product')
|
||||
.lean();
|
||||
}
|
||||
const stockLocationId = toId(listing?.stockLocation);
|
||||
if (stockLocationId) {
|
||||
const stockRollup = await aggregateRollups({
|
||||
model: mongoose.model('productStock'),
|
||||
baseFilter: {
|
||||
productSku: new mongoose.Types.ObjectId(productSkuId),
|
||||
stockLocation: new mongoose.Types.ObjectId(stockLocationId),
|
||||
},
|
||||
rollupConfigs: [
|
||||
{
|
||||
name: 'stockQuantity',
|
||||
rollups: [{ name: 'stockQuantity', property: 'currentQuantity', operation: 'sum' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
const stockQuantity = stockRollup.stockQuantity?.sum || 0;
|
||||
if (listingVarient.stockQuantity !== stockQuantity) {
|
||||
await editObject({
|
||||
model: this,
|
||||
id: varientId,
|
||||
updateData: { stockQuantity },
|
||||
user,
|
||||
recalculate: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!listingId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rollupResults = await aggregateRollups({
|
||||
model: this,
|
||||
baseFilter: { listing: new mongoose.Types.ObjectId(listingId) },
|
||||
rollupConfigs: [
|
||||
{
|
||||
name: 'stockQuantity',
|
||||
rollups: [{ name: 'stockQuantity', property: 'stockQuantity', operation: 'sum' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await editObject({
|
||||
model: mongoose.model('listing'),
|
||||
id: listingId,
|
||||
updateData: {
|
||||
stockQuantity: rollupResults.stockQuantity?.sum || 0,
|
||||
},
|
||||
user,
|
||||
recalculate: false,
|
||||
});
|
||||
};
|
||||
|
||||
export const listingVarientModel = mongoose.model('listingVarient', listingVarientSchema);
|
||||
|
||||
async function replaceSparseExternalReferenceIndex() {
|
||||
try {
|
||||
const indexes = await listingVarientModel.collection.indexes();
|
||||
const current = indexes.find((idx) => idx.name === 'listing_1_externalReference_1');
|
||||
if (current && (current.sparse || !current.partialFilterExpression)) {
|
||||
await listingVarientModel.collection.dropIndex('listing_1_externalReference_1');
|
||||
}
|
||||
await listingVarientModel.createIndexes();
|
||||
} catch {
|
||||
// Collection/index may not exist until Mongo is connected.
|
||||
}
|
||||
}
|
||||
|
||||
if (mongoose.connection.readyState === 1) {
|
||||
replaceSparseExternalReferenceIndex();
|
||||
} else {
|
||||
mongoose.connection.once('open', replaceSparseExternalReferenceIndex);
|
||||
}
|
||||
125
src/database/schemas/sales/marketplace.schema.js
Normal file
125
src/database/schemas/sales/marketplace.schema.js
Normal file
@ -0,0 +1,125 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { editObject, aggregateRollups, aggregateRollupsHistory } 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', 'disconnected'],
|
||||
default: 'offline',
|
||||
},
|
||||
message: { type: String, required: false },
|
||||
},
|
||||
// Provider-specific API configuration (flexible for eBay, Etsy, TikTok Shop)
|
||||
config: { type: mongoose.Schema.Types.Mixed, default: {} },
|
||||
defaultFulfillmentPolicy: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'fulfillmentPolicy',
|
||||
required: false,
|
||||
},
|
||||
defaultPaymentPolicy: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'paymentPolicy',
|
||||
required: false,
|
||||
},
|
||||
defaultReturnPolicy: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'returnPolicy',
|
||||
required: false,
|
||||
},
|
||||
eBay: {
|
||||
availableShippingServices: { type: [String], default: [] },
|
||||
categoryReferences: { 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,
|
||||
});
|
||||
};
|
||||
|
||||
const rollupConfigs = [
|
||||
{
|
||||
name: 'ready',
|
||||
filter: { 'state.type': 'ready' },
|
||||
rollups: [{ name: 'ready', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'syncing',
|
||||
filter: { 'state.type': 'syncing' },
|
||||
rollups: [{ name: 'syncing', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'disconnected',
|
||||
filter: { 'state.type': 'disconnected' },
|
||||
rollups: [{ name: 'disconnected', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'inactive',
|
||||
filter: { 'state.type': 'inactive' },
|
||||
rollups: [{ name: 'inactive', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
{
|
||||
name: 'offline',
|
||||
filter: { 'state.type': 'offline' },
|
||||
rollups: [{ name: 'offline', property: 'state.type', operation: 'count' }],
|
||||
},
|
||||
];
|
||||
|
||||
marketplaceSchema.statics.stats = async function () {
|
||||
const results = await aggregateRollups({
|
||||
model: this,
|
||||
rollupConfigs: rollupConfigs,
|
||||
});
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
marketplaceSchema.statics.history = async function (from, to) {
|
||||
const results = await aggregateRollupsHistory({
|
||||
model: this,
|
||||
startDate: from,
|
||||
endDate: to,
|
||||
rollupConfigs: rollupConfigs,
|
||||
});
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
marketplaceSchema.set('toJSON', { virtuals: true });
|
||||
|
||||
export const marketplaceModel = mongoose.model('marketplace', marketplaceSchema);
|
||||
24
src/database/schemas/sales/marketplaceMapping.schema.js
Normal file
24
src/database/schemas/sales/marketplaceMapping.schema.js
Normal file
@ -0,0 +1,24 @@
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
const { Schema } = mongoose;
|
||||
|
||||
export const MARKETPLACE_MAPPING_STATES = ['pending', 'syncing', 'ready', 'failed'];
|
||||
|
||||
export function marketplaceSyncMappingSchema() {
|
||||
return new Schema(
|
||||
{
|
||||
marketplace: { type: Schema.Types.ObjectId, ref: 'marketplace', required: true },
|
||||
externalReference: { type: String, required: false },
|
||||
syncHash: { type: String, required: false },
|
||||
state: {
|
||||
type: {
|
||||
type: String,
|
||||
enum: MARKETPLACE_MAPPING_STATES,
|
||||
default: 'pending',
|
||||
},
|
||||
message: { type: String, required: false },
|
||||
},
|
||||
},
|
||||
{ _id: true }
|
||||
);
|
||||
}
|
||||
31
src/database/schemas/sales/marketplaceevent.schema.js
Normal file
31
src/database/schemas/sales/marketplaceevent.schema.js
Normal file
@ -0,0 +1,31 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
|
||||
const marketplaceEventSchema = new mongoose.Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
marketplace: { type: mongoose.Schema.Types.ObjectId, ref: 'marketplace', required: true },
|
||||
externalReference: { type: String, required: true },
|
||||
topic: { type: String, required: true },
|
||||
status: {
|
||||
type: String,
|
||||
required: true,
|
||||
enum: ['received', 'processed', 'failed'],
|
||||
default: 'received',
|
||||
},
|
||||
processedAt: { type: Date, required: false },
|
||||
error: { type: String, required: false },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
marketplaceEventSchema.index({ marketplace: 1, externalReference: 1 }, { unique: true });
|
||||
marketplaceEventSchema.index({ topic: 'text', status: 'text' });
|
||||
|
||||
marketplaceEventSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
marketplaceEventSchema.set('toJSON', { virtuals: true });
|
||||
|
||||
export const marketplaceEventModel = mongoose.model('marketplaceEvent', marketplaceEventSchema);
|
||||
46
src/database/schemas/sales/returnpolicy.schema.js
Normal file
46
src/database/schemas/sales/returnpolicy.schema.js
Normal file
@ -0,0 +1,46 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
import { marketplaceSyncMappingSchema } from './marketplaceMapping.schema.js';
|
||||
|
||||
const returnPolicySchema = new mongoose.Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
name: { type: String, required: true },
|
||||
description: { type: String, required: false },
|
||||
returnsAccepted: { type: Boolean, required: true, default: true },
|
||||
returnPeriodDays: { type: Number, required: false, default: 30 },
|
||||
returnShippingCostPayer: {
|
||||
type: String,
|
||||
enum: ['buyer', 'seller'],
|
||||
required: false,
|
||||
default: 'buyer',
|
||||
},
|
||||
refundMethod: {
|
||||
type: String,
|
||||
enum: ['moneyBack', 'merchandiseCredit'],
|
||||
required: false,
|
||||
default: 'moneyBack',
|
||||
},
|
||||
restockingFeePercentage: { type: Number, required: false },
|
||||
returnInstructions: { type: String, required: false },
|
||||
internationalReturnsAccepted: { type: Boolean, required: false },
|
||||
internationalReturnPeriodDays: { type: Number, required: false },
|
||||
internationalReturnShippingCostPayer: {
|
||||
type: String,
|
||||
enum: ['buyer', 'seller'],
|
||||
required: false,
|
||||
},
|
||||
marketplaces: { type: [marketplaceSyncMappingSchema()], default: [] },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
returnPolicySchema.index({ name: 'text', description: 'text', returnInstructions: 'text' });
|
||||
|
||||
returnPolicySchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
returnPolicySchema.set('toJSON', { virtuals: true });
|
||||
|
||||
export const returnPolicyModel = mongoose.model('returnPolicy', returnPolicySchema);
|
||||
229
src/database/schemas/sales/salesorder.schema.js
Normal file
229
src/database/schemas/sales/salesorder.schema.js
Normal file
@ -0,0 +1,229 @@
|
||||
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 },
|
||||
externalReference: { type: String, 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' });
|
||||
salesOrderSchema.index({ marketplace: 1, externalReference: 1 }, { unique: true, sparse: true });
|
||||
|
||||
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' }],
|
||||
},
|
||||
{
|
||||
name: 'pipelineValue',
|
||||
filter: {
|
||||
'state.type': {
|
||||
$in: ['sent', 'confirmed', 'partiallyShipped', 'shipped', 'partiallyDelivered'],
|
||||
},
|
||||
},
|
||||
rollups: [{ name: 'grandTotalAmount', property: 'grandTotalAmount', operation: 'sum' }],
|
||||
},
|
||||
{
|
||||
name: 'completedValue',
|
||||
filter: { 'state.type': { $in: ['delivered', 'completed'] } },
|
||||
rollups: [{ name: 'grandTotalAmount', property: 'grandTotalAmount', operation: 'sum' }],
|
||||
},
|
||||
];
|
||||
|
||||
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);
|
||||
54
src/database/tax.js
Normal file
54
src/database/tax.js
Normal file
@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Tax calculation helpers mirroring farmcontrol-ui model value functions.
|
||||
*/
|
||||
|
||||
export function isPopulatedTaxRate(taxRate) {
|
||||
return taxRate != null && taxRate.rateType != null;
|
||||
}
|
||||
|
||||
export async function resolveTaxRate(taxRateRef, getObject, taxRateModel) {
|
||||
if (!taxRateRef) {
|
||||
return null;
|
||||
}
|
||||
if (isPopulatedTaxRate(taxRateRef)) {
|
||||
return taxRateRef;
|
||||
}
|
||||
const id = taxRateRef._id ?? taxRateRef;
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
typeof taxRateRef === 'object' &&
|
||||
taxRateRef._id &&
|
||||
Object.keys(taxRateRef).length === 1
|
||||
) {
|
||||
return await getObject({ model: taxRateModel, id, cached: true });
|
||||
}
|
||||
return await getObject({ model: taxRateModel, id, cached: true });
|
||||
}
|
||||
|
||||
export function amountWithTax(amount, taxRate) {
|
||||
const base = Number.parseFloat(amount) || 0;
|
||||
if (!base) {
|
||||
return 0;
|
||||
}
|
||||
if (!taxRate) {
|
||||
return Number.parseFloat(base.toFixed(2));
|
||||
}
|
||||
|
||||
const rate = Number.parseFloat(taxRate.rate) || 0;
|
||||
if (taxRate.rateType === 'percentage') {
|
||||
return Number.parseFloat((base * (1 + rate / 100)).toFixed(2));
|
||||
}
|
||||
if (taxRate.rateType === 'amount' || taxRate.rateType === 'fixed') {
|
||||
return Number.parseFloat((base + rate).toFixed(2));
|
||||
}
|
||||
return Number.parseFloat(base.toFixed(2));
|
||||
}
|
||||
|
||||
export function effectiveMarginPrice({ priceMode, price, cost, margin }) {
|
||||
if (priceMode === 'margin' && margin != null && cost != null) {
|
||||
return cost * (1 + margin / 100);
|
||||
}
|
||||
return price;
|
||||
}
|
||||
755
src/database/utils.js
Normal file
755
src/database/utils.js
Normal file
@ -0,0 +1,755 @@
|
||||
import { ObjectId } from 'mongodb';
|
||||
import { auditLogModel } from './schemas/management/auditlog.schema.js';
|
||||
import { notificationModel } from './schemas/misc/notification.schema.js';
|
||||
import { userNotifierModel } from './schemas/misc/usernotifier.schema.js';
|
||||
import { natsServer } from './nats.js';
|
||||
|
||||
import { customAlphabet } from 'nanoid';
|
||||
|
||||
const NOTIFICATION_EXCLUDED_MODELS = [
|
||||
'notification',
|
||||
'userNotifier',
|
||||
'objectView',
|
||||
'auditLog'
|
||||
];
|
||||
const AUDIT_EXCLUDED_MODELS = [
|
||||
'notification',
|
||||
'userNotifier',
|
||||
'objectView',
|
||||
'marketplaceEvent'
|
||||
];
|
||||
const AUDIT_EXCLUDED_CHANGES = ['state.message'];
|
||||
const SENSITIVE_KEYS = ['secret'];
|
||||
|
||||
function omitSensitive(obj) {
|
||||
if (obj == null || typeof obj !== 'object') return obj;
|
||||
if (Array.isArray(obj)) return obj.map(omitSensitive);
|
||||
const result = {};
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (SENSITIVE_KEYS.includes(key)) continue;
|
||||
result[key] = omitSensitive(value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function omitPath(obj, path) {
|
||||
if (obj == null || typeof obj !== 'object') return obj;
|
||||
const keys = path.split('.');
|
||||
if (keys.length === 1) {
|
||||
const result = { ...obj };
|
||||
delete result[keys[0]];
|
||||
return result;
|
||||
}
|
||||
const [first, ...rest] = keys;
|
||||
if (
|
||||
obj[first] == null ||
|
||||
typeof obj[first] !== 'object' ||
|
||||
Array.isArray(obj[first])
|
||||
) {
|
||||
return obj;
|
||||
}
|
||||
const nested = obj[first];
|
||||
const leafKey = rest[rest.length - 1];
|
||||
const result = { ...obj };
|
||||
|
||||
if (rest.length === 1) {
|
||||
const nestedKeys = Object.keys(nested);
|
||||
if (nestedKeys.length === 1 && nestedKeys[0] === leafKey) {
|
||||
delete result[first];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const nestedKeys = Object.keys(nested);
|
||||
if (
|
||||
nestedKeys.length > 1 ||
|
||||
(nestedKeys.length === 1 && nestedKeys[0] !== rest[0])
|
||||
) {
|
||||
return obj;
|
||||
}
|
||||
|
||||
const updatedNested = omitPath({ ...nested }, rest.join('.'));
|
||||
if (updatedNested == null || Object.keys(updatedNested).length === 0) {
|
||||
delete result[first];
|
||||
} else {
|
||||
result[first] = updatedNested;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function omitExcludedChanges(obj) {
|
||||
if (obj == null || typeof obj !== 'object') return obj;
|
||||
return AUDIT_EXCLUDED_CHANGES.reduce((acc, path) => omitPath(acc, path), {
|
||||
...obj
|
||||
});
|
||||
}
|
||||
|
||||
let modelsCache = null;
|
||||
|
||||
async function getModelEntryByType(parentType) {
|
||||
if (!modelsCache) {
|
||||
modelsCache = (await import('./schemas/models.js')).models;
|
||||
}
|
||||
return Object.values(modelsCache).find(
|
||||
entry => entry.type === parentType || entry.model?.modelName === parentType
|
||||
);
|
||||
}
|
||||
|
||||
function notificationUserFromOwner(owner, ownerType) {
|
||||
if (!owner) return null;
|
||||
if (ownerType === 'user') {
|
||||
return owner;
|
||||
}
|
||||
if (ownerType === 'host' || ownerType === 'marketplace') {
|
||||
return {
|
||||
_id: owner._id,
|
||||
firstName: owner.name ?? 'unknown',
|
||||
lastName: ''
|
||||
};
|
||||
}
|
||||
return {
|
||||
_id: owner._id,
|
||||
firstName: owner.name ?? owner.firstName ?? 'unknown',
|
||||
lastName: owner.lastName ?? ''
|
||||
};
|
||||
}
|
||||
|
||||
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
export const generateId = () => {
|
||||
// 10 characters
|
||||
return customAlphabet(ALPHABET, 12);
|
||||
};
|
||||
|
||||
function parseFilter(property, value) {
|
||||
if (typeof value === 'string') {
|
||||
var trimmed = value.trim();
|
||||
if (trimmed.charAt(3) == ':') {
|
||||
trimmed = value.split(':')[1];
|
||||
}
|
||||
|
||||
// Handle booleans
|
||||
if (trimmed.toLowerCase() === 'true') return { [property]: true };
|
||||
if (trimmed.toLowerCase() === 'false') return { [property]: false };
|
||||
|
||||
// Handle ObjectId (24-char hex)
|
||||
|
||||
if (/^[a-f\d]{24}$/i.test(trimmed) && trimmed.length >= 24) {
|
||||
return { [property]: new ObjectId(trimmed) };
|
||||
}
|
||||
|
||||
// Handle numbers
|
||||
if (!isNaN(trimmed)) {
|
||||
return { [property]: parseFloat(trimmed) };
|
||||
}
|
||||
|
||||
// Default to case-insensitive regex for non-numeric strings
|
||||
return {
|
||||
[property]: {
|
||||
$regex: trimmed,
|
||||
$options: 'i'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Handle actual booleans, numbers, objects, etc.
|
||||
return { [property]: value };
|
||||
}
|
||||
|
||||
function convertToCamelCase(obj) {
|
||||
const result = {};
|
||||
|
||||
for (const key in obj) {
|
||||
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
||||
const value = obj[key];
|
||||
|
||||
// Convert the key to camelCase
|
||||
let camelKey = key
|
||||
// First handle special cases with spaces, brackets and other characters
|
||||
.replace(/\s*\[.*?\]\s*/g, '') // Remove brackets and their contents
|
||||
.replace(/\s+/g, ' ') // Normalize spaces
|
||||
.trim()
|
||||
// Split by common separators (space, underscore, hyphen)
|
||||
.split(/[\s_-]/)
|
||||
// Convert to camelCase
|
||||
.map((word, index) => {
|
||||
// Remove any non-alphanumeric characters
|
||||
word = word.replace(/[^a-zA-Z0-9]/g, '');
|
||||
|
||||
// Lowercase first word, uppercase others
|
||||
return index === 0
|
||||
? word.toLowerCase()
|
||||
: word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
|
||||
})
|
||||
.join('');
|
||||
|
||||
// Handle values that are objects recursively
|
||||
if (
|
||||
value !== null &&
|
||||
typeof value === 'object' &&
|
||||
!Array.isArray(value)
|
||||
) {
|
||||
result[camelKey] = convertToCamelCase(value);
|
||||
} else {
|
||||
result[camelKey] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function extractConfigBlock(fileContent, useCamelCase = true) {
|
||||
const configObject = {};
|
||||
|
||||
// Extract header information
|
||||
const headerBlockRegex =
|
||||
/; HEADER_BLOCK_START([\s\S]*?)(?:; HEADER_BLOCK_END|$)/;
|
||||
const headerBlockMatch = fileContent.match(headerBlockRegex);
|
||||
if (headerBlockMatch && headerBlockMatch[1]) {
|
||||
const headerLines = headerBlockMatch[1].split('\n');
|
||||
headerLines.forEach(line => {
|
||||
const keyValueRegex = /^\s*;\s*([^:]+?):\s*(.*?)\s*$/;
|
||||
const simpleValueRegex = /^\s*;\s*(.*?)\s*$/;
|
||||
|
||||
// Try key-value format first
|
||||
let match = line.match(keyValueRegex);
|
||||
if (match) {
|
||||
const key = match[1].trim();
|
||||
let value = match[2].trim();
|
||||
|
||||
// Try to convert value to appropriate type
|
||||
if (!isNaN(value) && value !== '') {
|
||||
value = Number(value);
|
||||
}
|
||||
configObject[key] = value;
|
||||
} else {
|
||||
// Try the simple format like "; generated by OrcaSlicer 2.1.1 on 2025-04-28 at 13:30:11"
|
||||
match = line.match(simpleValueRegex);
|
||||
if (match && match[1] && !match[1].includes('HEADER_BLOCK')) {
|
||||
const text = match[1].trim();
|
||||
|
||||
// Extract slicer info
|
||||
const slicerMatch = text.match(
|
||||
/generated by (.*?) on (.*?) at (.*?)$/
|
||||
);
|
||||
if (slicerMatch) {
|
||||
configObject['slicer'] = slicerMatch[1].trim();
|
||||
configObject['date'] = slicerMatch[2].trim();
|
||||
configObject['time'] = slicerMatch[3].trim();
|
||||
} else {
|
||||
// Just add as a general header entry if it doesn't match any specific pattern
|
||||
const key = `header_${Object.keys(configObject).length}`;
|
||||
configObject[key] = text;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Extract thumbnail data
|
||||
const thumbnailBlockRegex =
|
||||
/; THUMBNAIL_BLOCK_START([\s\S]*?)(?:; THUMBNAIL_BLOCK_END|$)/;
|
||||
const thumbnailBlockMatch = fileContent.match(thumbnailBlockRegex);
|
||||
if (thumbnailBlockMatch && thumbnailBlockMatch[1]) {
|
||||
const thumbnailLines = thumbnailBlockMatch[1].split('\n');
|
||||
let base64Data = '';
|
||||
let thumbnailInfo = {};
|
||||
|
||||
thumbnailLines.forEach(line => {
|
||||
// Extract thumbnail dimensions and size from the line "thumbnail begin 640x640 27540"
|
||||
const thumbnailHeaderRegex = /^\s*;\s*thumbnail begin (\d+)x(\d+) (\d+)/;
|
||||
const match = line.match(thumbnailHeaderRegex);
|
||||
|
||||
if (match) {
|
||||
thumbnailInfo.width = parseInt(match[1], 10);
|
||||
thumbnailInfo.height = parseInt(match[2], 10);
|
||||
thumbnailInfo.size = parseInt(match[3], 10);
|
||||
} else if (
|
||||
line.trim().startsWith('; ') &&
|
||||
!line.includes('THUMBNAIL_BLOCK')
|
||||
) {
|
||||
// Collect base64 data (remove the leading semicolon and space and thumbnail end)
|
||||
const dataLine = line.trim().substring(2);
|
||||
if (dataLine && dataLine != 'thumbnail end') {
|
||||
base64Data += dataLine;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Add thumbnail data to config object
|
||||
if (base64Data) {
|
||||
configObject.thumbnail = {
|
||||
data: base64Data,
|
||||
...thumbnailInfo
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Extract CONFIG_BLOCK
|
||||
const configBlockRegex =
|
||||
/; CONFIG_BLOCK_START([\s\S]*?)(?:; CONFIG_BLOCK_END|$)/;
|
||||
const configBlockMatch = fileContent.match(configBlockRegex);
|
||||
if (configBlockMatch && configBlockMatch[1]) {
|
||||
// Extract each config line
|
||||
const configLines = configBlockMatch[1].split('\n');
|
||||
// Process each line
|
||||
configLines.forEach(line => {
|
||||
// Check if the line starts with a semicolon and has an equals sign
|
||||
const configLineRegex = /^\s*;\s*([^=]+?)\s*=\s*(.*?)\s*$/;
|
||||
const match = line.match(configLineRegex);
|
||||
if (match) {
|
||||
const key = match[1].trim();
|
||||
let value = match[2].trim();
|
||||
// Try to convert value to appropriate type
|
||||
if (value === 'true' || value === 'false') {
|
||||
value = value === 'true';
|
||||
} else if (!isNaN(value) && value !== '') {
|
||||
// Check if it's a number (but not a percentage)
|
||||
if (!value.includes('%')) {
|
||||
value = Number(value);
|
||||
}
|
||||
}
|
||||
configObject[key] = value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Extract additional variables that appear after EXECUTABLE_BLOCK_END
|
||||
const additionalVarsRegex =
|
||||
/; EXECUTABLE_BLOCK_(?:START|END)([\s\S]*?)(?:; CONFIG_BLOCK_START|$)/i;
|
||||
const additionalVarsMatch = fileContent.match(additionalVarsRegex);
|
||||
if (additionalVarsMatch && additionalVarsMatch[1]) {
|
||||
const additionalLines = additionalVarsMatch[1].split('\n');
|
||||
additionalLines.forEach(line => {
|
||||
// Match both standard format and the special case for "total filament cost"
|
||||
const varRegex =
|
||||
/^\s*;\s*((?:filament used|filament cost|total filament used|total filament cost|total layers count|estimated printing time)[^=]*?)\s*=\s*(.*?)\s*$/;
|
||||
const match = line.match(varRegex);
|
||||
if (match) {
|
||||
const key = match[1].replace(/\[([^\]]+)\]/g, '$1').trim();
|
||||
let value = match[2].trim();
|
||||
// Clean up values - remove units in brackets and handle special cases
|
||||
if (key.includes('filament used')) {
|
||||
// Extract just the numeric value, ignoring units in brackets
|
||||
const numMatch = value.match(/(\d+\.\d+)/);
|
||||
if (numMatch) {
|
||||
value = parseFloat(numMatch[1]);
|
||||
}
|
||||
} else if (key.includes('filament cost')) {
|
||||
// Extract just the numeric value
|
||||
const numMatch = value.match(/(\d+\.\d+)/);
|
||||
if (numMatch) {
|
||||
value = parseFloat(numMatch[1]);
|
||||
}
|
||||
} else if (key.includes('total layers count')) {
|
||||
value = parseInt(value, 10);
|
||||
} else if (key.includes('estimated printing time')) {
|
||||
// Keep as string but trim any additional whitespace
|
||||
value = value.trim();
|
||||
}
|
||||
configObject[key] = value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Also extract extrusion width settings
|
||||
const extrusionWidthRegex = /;\s*(.*?)\s*extrusion width\s*=\s*(.*?)mm/g;
|
||||
let extrusionMatch;
|
||||
while ((extrusionMatch = extrusionWidthRegex.exec(fileContent)) !== null) {
|
||||
const settingName = extrusionMatch[1].trim();
|
||||
const settingValue = parseFloat(extrusionMatch[2].trim());
|
||||
configObject[`${settingName} extrusion width`] = settingValue;
|
||||
}
|
||||
|
||||
// Extract additional parameters after CONFIG_BLOCK_END if they exist
|
||||
const postConfigParams = /; CONFIG_BLOCK_END\s*\n([\s\S]*?)$/;
|
||||
const postConfigMatch = fileContent.match(postConfigParams);
|
||||
if (postConfigMatch && postConfigMatch[1]) {
|
||||
const postConfigLines = postConfigMatch[1].split('\n');
|
||||
postConfigLines.forEach(line => {
|
||||
// Match lines with format "; parameter_name = value"
|
||||
const paramRegex = /^\s*;\s*([^=]+?)\s*=\s*(.*?)\s*$/;
|
||||
const match = line.match(paramRegex);
|
||||
if (match) {
|
||||
const key = match[1].trim();
|
||||
let value = match[2].trim();
|
||||
|
||||
// Try to convert value to appropriate type
|
||||
if (value === 'true' || value === 'false') {
|
||||
value = value === 'true';
|
||||
} else if (!isNaN(value) && value !== '') {
|
||||
// Check if it's a number (but not a percentage)
|
||||
if (!value.includes('%')) {
|
||||
value = Number(value);
|
||||
}
|
||||
}
|
||||
|
||||
// Add to config object if not already present
|
||||
if (!configObject[key]) {
|
||||
configObject[key] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Apply camelCase conversion if requested
|
||||
return useCamelCase ? convertToCamelCase(configObject) : configObject;
|
||||
}
|
||||
|
||||
function getChangedValues(oldObj, newObj, old = false) {
|
||||
const changes = {};
|
||||
|
||||
const combinedObj = { ...oldObj, ...newObj };
|
||||
|
||||
// Check all keys in the new object
|
||||
for (const key in combinedObj) {
|
||||
// Skip if the key is _id or timestamps
|
||||
if (key === 'createdAt' || key === 'updatedAt' || key === '_id') continue;
|
||||
|
||||
const oldVal = oldObj ? oldObj[key] : undefined;
|
||||
const newVal = newObj ? newObj[key] : undefined;
|
||||
|
||||
// If both values are objects (but not arrays or null), recurse
|
||||
if (
|
||||
oldVal &&
|
||||
newVal &&
|
||||
typeof oldVal === 'object' &&
|
||||
typeof newVal === 'object' &&
|
||||
!Array.isArray(oldVal) &&
|
||||
!Array.isArray(newVal) &&
|
||||
oldVal !== null &&
|
||||
newVal !== null
|
||||
) {
|
||||
if (oldVal?._id || newVal?._id) {
|
||||
if (JSON.stringify(oldVal?._id) !== JSON.stringify(newVal?._id)) {
|
||||
changes[key] = old ? oldVal : newVal;
|
||||
}
|
||||
} else {
|
||||
const nestedChanges = getChangedValues(oldVal, newVal, old);
|
||||
if (Object.keys(nestedChanges).length > 0) {
|
||||
// Exclude progress and currentWeight from nested changes
|
||||
const excludeKeys = ['progress', 'currentWeight', 'net', 'gross'];
|
||||
const filteredChanges = Object.keys(nestedChanges)
|
||||
.filter(nestedKey => !excludeKeys.includes(nestedKey))
|
||||
.reduce((acc, nestedKey) => {
|
||||
acc[nestedKey] = nestedChanges[nestedKey];
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
if (Object.keys(filteredChanges).length > 0) {
|
||||
changes[key] = filteredChanges;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (JSON.stringify(oldVal) !== JSON.stringify(newVal)) {
|
||||
// If the old value is different from the new value, include it
|
||||
changes[key] = old ? oldVal : newVal;
|
||||
}
|
||||
}
|
||||
|
||||
return changes;
|
||||
}
|
||||
|
||||
async function newAuditLog(newValue, parentId, parentType, owner, ownerType) {
|
||||
if (AUDIT_EXCLUDED_MODELS.includes(parentType)) return;
|
||||
|
||||
// Filter out createdAt, updatedAt, and sensitive fields from newValue
|
||||
const filteredNewValue = omitSensitive({ ...newValue });
|
||||
delete filteredNewValue.createdAt;
|
||||
delete filteredNewValue.updatedAt;
|
||||
const auditLog = new auditLogModel({
|
||||
changes: {
|
||||
new: filteredNewValue
|
||||
},
|
||||
parent: parentId,
|
||||
parentType,
|
||||
owner: owner._id,
|
||||
ownerType: ownerType,
|
||||
operation: 'new'
|
||||
});
|
||||
|
||||
await auditLog.save();
|
||||
|
||||
await distributeNew(auditLog, 'auditLog');
|
||||
}
|
||||
|
||||
async function editAuditLog(
|
||||
oldValue,
|
||||
newValue,
|
||||
parentId,
|
||||
parentType,
|
||||
owner,
|
||||
ownerType
|
||||
) {
|
||||
if (parentType === 'stockEvent') {
|
||||
return;
|
||||
}
|
||||
const filteredOldValue = omitExcludedChanges(oldValue);
|
||||
const filteredNewValue = omitExcludedChanges(newValue);
|
||||
// Get only the changed values
|
||||
const changedOldValues = getChangedValues(
|
||||
filteredOldValue,
|
||||
filteredNewValue,
|
||||
true
|
||||
);
|
||||
const changedNewValues = getChangedValues(
|
||||
filteredOldValue,
|
||||
filteredNewValue,
|
||||
false
|
||||
);
|
||||
|
||||
// If no values changed, don't create an audit log
|
||||
if (
|
||||
Object.keys(changedOldValues).length === 0 ||
|
||||
Object.keys(changedNewValues).length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const auditLog = new auditLogModel({
|
||||
changes: {
|
||||
old: changedOldValues,
|
||||
new: changedNewValues
|
||||
},
|
||||
parent: parentId,
|
||||
parentType,
|
||||
owner: owner._id,
|
||||
ownerType: ownerType,
|
||||
operation: 'edit'
|
||||
});
|
||||
|
||||
await auditLog.save();
|
||||
|
||||
await distributeNew(auditLog, 'auditLog');
|
||||
}
|
||||
|
||||
async function deleteAuditLog(
|
||||
deleteValue,
|
||||
parentId,
|
||||
parentType,
|
||||
owner,
|
||||
ownerType
|
||||
) {
|
||||
const auditLog = new auditLogModel({
|
||||
changes: {
|
||||
old: deleteValue
|
||||
},
|
||||
parent: parentId,
|
||||
parentType,
|
||||
owner: owner._id,
|
||||
ownerType: ownerType,
|
||||
operation: 'delete'
|
||||
});
|
||||
|
||||
await auditLog.save();
|
||||
|
||||
await distributeNew(auditLog, 'auditLog');
|
||||
}
|
||||
|
||||
async function getAuditLogs(idOrIds) {
|
||||
if (Array.isArray(idOrIds)) {
|
||||
return auditLogModel.find({ parent: { $in: idOrIds } }).populate('owner');
|
||||
} else {
|
||||
return auditLogModel.find({ parent: idOrIds }).populate('owner');
|
||||
}
|
||||
}
|
||||
|
||||
async function distributeUpdate(value, id, type) {
|
||||
await natsServer.publish(`${type}s.${id}.object`, { ...value, _id: id });
|
||||
}
|
||||
|
||||
async function distributeStats(value, type) {
|
||||
await natsServer.publish(`${type}s.stats`, value);
|
||||
}
|
||||
|
||||
async function distributeNew(value, type) {
|
||||
await natsServer.publish(`${type}s.new`, value);
|
||||
}
|
||||
|
||||
async function editNotification(
|
||||
oldValue,
|
||||
newValue,
|
||||
parentId,
|
||||
parentType,
|
||||
owner,
|
||||
ownerType
|
||||
) {
|
||||
if (NOTIFICATION_EXCLUDED_MODELS.includes(parentType)) return;
|
||||
|
||||
const modelEntry = await getModelEntryByType(parentType);
|
||||
const user = notificationUserFromOwner(owner, ownerType);
|
||||
const objectName =
|
||||
oldValue?.name ?? newValue?.name ?? modelEntry?.label ?? parentType;
|
||||
const changedOldValues = omitSensitive(
|
||||
getChangedValues(oldValue, newValue, true)
|
||||
);
|
||||
const changedNewValues = omitSensitive(
|
||||
getChangedValues(oldValue, newValue, false)
|
||||
);
|
||||
|
||||
if (
|
||||
Object.keys(changedOldValues).length === 0 ||
|
||||
Object.keys(changedNewValues).length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
await notfiyObjectUserNotifiers(
|
||||
parentId,
|
||||
parentType,
|
||||
`${objectName} edited by ${user?.firstName ?? 'unknown'} ${user?.lastName ?? ''}`,
|
||||
`The ${parentType} ${parentId} has been updated.`,
|
||||
'editObject',
|
||||
{
|
||||
old: changedOldValues,
|
||||
new: changedNewValues,
|
||||
objectType: parentType,
|
||||
object: { _id: String(parentId ?? '') },
|
||||
user: {
|
||||
_id: String(user?._id ?? ''),
|
||||
firstName: user?.firstName,
|
||||
lastName: user?.lastName
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function notfiyObjectUserNotifiers(
|
||||
id,
|
||||
objectType,
|
||||
title,
|
||||
message,
|
||||
type = 'info',
|
||||
metadata
|
||||
) {
|
||||
const userNotifiers = await userNotifierModel
|
||||
.find({ object: id, objectType })
|
||||
.populate('user');
|
||||
for (const userNotifier of userNotifiers) {
|
||||
await createNotification(
|
||||
userNotifier.user._id,
|
||||
title,
|
||||
message,
|
||||
type,
|
||||
metadata
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function createNotification(
|
||||
user,
|
||||
title,
|
||||
message,
|
||||
type = 'info',
|
||||
metadata
|
||||
) {
|
||||
const notification = new notificationModel({
|
||||
user,
|
||||
title,
|
||||
message,
|
||||
type,
|
||||
metadata: omitSensitive(metadata ?? {})
|
||||
});
|
||||
await notification.save();
|
||||
const value = notification.toJSON ? notification.toJSON() : notification;
|
||||
await natsServer.publish(`notifications.${user._id ?? user}`, value);
|
||||
return notification;
|
||||
}
|
||||
|
||||
function flatternObjectIds(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) {
|
||||
// If the value is an object with _id, convert to just the _id
|
||||
result[key] = value._id;
|
||||
} else {
|
||||
// Keep primitive values as is
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function expandObjectIds(input) {
|
||||
// Helper to check if a value is an ObjectId or a 24-char hex string
|
||||
function isObjectId(val) {
|
||||
// Check for MongoDB ObjectId instance
|
||||
if (val instanceof ObjectId) return true;
|
||||
// Check for exactly 24 hex characters (no special characters)
|
||||
if (typeof val === 'string' && /^[a-fA-F\d]{24}$/.test(val)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Recursive function
|
||||
function expand(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(expand);
|
||||
} else if (
|
||||
value &&
|
||||
typeof value === 'object' &&
|
||||
!(value instanceof ObjectId)
|
||||
) {
|
||||
var result = {};
|
||||
for (const [key, val] of Object.entries(value)) {
|
||||
if (key === '_id') {
|
||||
// Do not expand keys that are already named _id
|
||||
result[key] = val;
|
||||
} else if (isObjectId(val)) {
|
||||
result[key] = { _id: val };
|
||||
} else if (Array.isArray(val)) {
|
||||
result[key] = val.map(expand);
|
||||
} else if (val instanceof Date) {
|
||||
result[key] = val;
|
||||
} else if (val && typeof val === 'object') {
|
||||
result[key] = expand(val);
|
||||
} else {
|
||||
result[key] = val;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
} else if (isObjectId(value)) {
|
||||
return { _id: value };
|
||||
} else {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return expand(input);
|
||||
}
|
||||
|
||||
export { getFilter } from './filter.js';
|
||||
|
||||
// Converts a properties argument (string or array) to an array of strings
|
||||
function convertPropertiesString(properties) {
|
||||
if (typeof properties === 'string') {
|
||||
return properties.split(',');
|
||||
} else if (!Array.isArray(properties)) {
|
||||
return [];
|
||||
}
|
||||
return properties;
|
||||
}
|
||||
|
||||
export {
|
||||
parseFilter,
|
||||
convertToCamelCase,
|
||||
extractConfigBlock,
|
||||
newAuditLog,
|
||||
editAuditLog,
|
||||
deleteAuditLog,
|
||||
getAuditLogs,
|
||||
flatternObjectIds,
|
||||
expandObjectIds,
|
||||
distributeUpdate,
|
||||
distributeNew,
|
||||
distributeStats,
|
||||
editNotification,
|
||||
notfiyObjectUserNotifiers,
|
||||
createNotification,
|
||||
convertPropertiesString
|
||||
};
|
||||
57
src/index.js
Normal file
57
src/index.js
Normal file
@ -0,0 +1,57 @@
|
||||
import { loadConfig } from "./config.js";
|
||||
import { SchedulerManager } from "./scheduler/schedulermanager.js";
|
||||
import { natsServer } from "./database/nats.js";
|
||||
import { redisServer } from "./database/redis.js";
|
||||
import log4js from "log4js";
|
||||
import { mongoServer } from "./database/mongo.js";
|
||||
|
||||
(async () => {
|
||||
// Load configuration
|
||||
const config = loadConfig();
|
||||
|
||||
// Setup logger
|
||||
const logger = log4js.getLogger("FarmControl Scheduler");
|
||||
logger.level = config.server.logLevel;
|
||||
|
||||
// Connect to NATS (await)
|
||||
try {
|
||||
await natsServer.connect();
|
||||
} catch (err) {
|
||||
logger.error("Failed to connect to NATS:", err);
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Connect to Mongo DB (await)
|
||||
try {
|
||||
await mongoServer.connect();
|
||||
} catch (err) {
|
||||
logger.error("Failed to connect to Mongo DB:", err);
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Connect to Redis (await)
|
||||
try {
|
||||
await redisServer.connect();
|
||||
} catch (err) {
|
||||
logger.error("Failed to connect to Redis:", err);
|
||||
throw err;
|
||||
}
|
||||
|
||||
const schedulerManager = new SchedulerManager(
|
||||
redisServer,
|
||||
mongoServer,
|
||||
natsServer,
|
||||
);
|
||||
|
||||
await schedulerManager.start();
|
||||
|
||||
process.on("SIGINT", async () => {
|
||||
logger.info("Shutting down...");
|
||||
await schedulerManager.stop();
|
||||
await redisServer.disconnect();
|
||||
await mongoServer.disconnect();
|
||||
await natsServer.disconnect();
|
||||
logger.info("Shutdown complete");
|
||||
process.exit(0);
|
||||
});
|
||||
})();
|
||||
57
src/scheduler/__tests__/scheduler.test.js
Normal file
57
src/scheduler/__tests__/scheduler.test.js
Normal file
@ -0,0 +1,57 @@
|
||||
import { jest } from "@jest/globals";
|
||||
import { Scheduler } from "../scheduler.js";
|
||||
|
||||
describe("Scheduler", () => {
|
||||
const object = { _id: "inv-1" };
|
||||
const model = { onSchedulerEvent: jest.fn() };
|
||||
|
||||
it("should generate an id and coerce a string value to a Date", () => {
|
||||
const scheduler = new Scheduler({
|
||||
objectType: "invoice",
|
||||
object,
|
||||
property: "dueAt",
|
||||
value: "2026-09-06T12:00:00.000Z",
|
||||
model,
|
||||
});
|
||||
|
||||
expect(scheduler.id).toEqual(expect.any(String));
|
||||
expect(scheduler.objectType).toBe("invoice");
|
||||
expect(scheduler.object).toBe(object);
|
||||
expect(scheduler.property).toBe("dueAt");
|
||||
expect(scheduler.model).toBe(model);
|
||||
expect(scheduler.value).toEqual(new Date("2026-09-06T12:00:00.000Z"));
|
||||
});
|
||||
|
||||
it("should keep an explicit id and Date value", () => {
|
||||
const value = new Date("2026-09-07T09:00:00.000Z");
|
||||
const scheduler = new Scheduler({
|
||||
id: "scheduler-1",
|
||||
objectType: "invoice",
|
||||
object,
|
||||
property: "dueAt",
|
||||
value,
|
||||
model,
|
||||
});
|
||||
|
||||
expect(scheduler.id).toBe("scheduler-1");
|
||||
expect(scheduler.value).toBe(value);
|
||||
});
|
||||
|
||||
it("should serialize only the worker-safe payload", () => {
|
||||
const scheduler = new Scheduler({
|
||||
id: "scheduler-1",
|
||||
objectType: "invoice",
|
||||
object,
|
||||
property: "dueAt",
|
||||
value: "2026-09-06T12:00:00.000Z",
|
||||
model,
|
||||
});
|
||||
|
||||
expect(scheduler.toPayload()).toEqual({
|
||||
id: "scheduler-1",
|
||||
objectType: "invoice",
|
||||
property: "dueAt",
|
||||
value: "2026-09-06T12:00:00.000Z",
|
||||
});
|
||||
});
|
||||
});
|
||||
520
src/scheduler/__tests__/schedulermanager.test.js
Normal file
520
src/scheduler/__tests__/schedulermanager.test.js
Normal file
@ -0,0 +1,520 @@
|
||||
import { jest } from "@jest/globals";
|
||||
|
||||
const workerInstances = [];
|
||||
|
||||
jest.unstable_mockModule("worker_threads", () => ({
|
||||
Worker: class Worker {
|
||||
constructor(filename) {
|
||||
this.filename = filename;
|
||||
this.postMessage = jest.fn();
|
||||
this.terminate = jest.fn().mockResolvedValue(undefined);
|
||||
this.listeners = {};
|
||||
workerInstances.push(this);
|
||||
}
|
||||
|
||||
on(event, handler) {
|
||||
this.listeners[event] = handler;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule("log4js", () => ({
|
||||
default: {
|
||||
getLogger: () => ({
|
||||
level: "info",
|
||||
debug: jest.fn(),
|
||||
error: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
trace: jest.fn(),
|
||||
info: jest.fn(),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule("../../config.js", () => ({
|
||||
loadConfig: jest.fn(() => ({
|
||||
server: {
|
||||
logLevel: "info",
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
const onSchedulerEvent = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
const invoiceMongooseModel = {
|
||||
scheduledProperties: [
|
||||
{
|
||||
name: "dueAt",
|
||||
filter: { state: "due|sent|acknowledged" },
|
||||
type: "dateTime",
|
||||
},
|
||||
{
|
||||
name: "remindAt",
|
||||
filter: { state: "due|sent|acknowledged" },
|
||||
type: "dateTime",
|
||||
},
|
||||
],
|
||||
onSchedulerEvent,
|
||||
};
|
||||
|
||||
const invoiceModelEntry = {
|
||||
type: "invoice",
|
||||
label: "Invoice",
|
||||
model: invoiceMongooseModel,
|
||||
};
|
||||
|
||||
const printerModelEntry = {
|
||||
type: "printer",
|
||||
label: "Printer",
|
||||
model: {},
|
||||
};
|
||||
|
||||
jest.unstable_mockModule("../../database/schemas/models.js", () => ({
|
||||
models: {
|
||||
INV: invoiceModelEntry,
|
||||
PRN: printerModelEntry,
|
||||
},
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule("../../database/database.js", () => ({
|
||||
listObjects: jest.fn(async () => []),
|
||||
getObject: jest.fn(async ({ id }) => ({ _id: id })),
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule("../../database/utils.js", () => ({
|
||||
getFilter: jest.fn(async (filter) => filter),
|
||||
}));
|
||||
|
||||
const updateManagerInstances = [];
|
||||
|
||||
jest.unstable_mockModule("../../updates/updatemanager.js", () => ({
|
||||
UpdateManager: class UpdateManager {
|
||||
constructor(socketClient) {
|
||||
this.socketClient = socketClient;
|
||||
this.subscribeToObjectNew = jest.fn().mockResolvedValue(undefined);
|
||||
this.subscribeToObjectDelete = jest.fn().mockResolvedValue(undefined);
|
||||
this.subscribeToAllObjectUpdates = jest.fn().mockResolvedValue(undefined);
|
||||
this.removeAllListeners = jest.fn().mockResolvedValue(undefined);
|
||||
updateManagerInstances.push(this);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
const { SchedulerManager } = await import("../schedulermanager.js");
|
||||
const { Scheduler } = await import("../scheduler.js");
|
||||
const { listObjects, getObject } = await import("../../database/database.js");
|
||||
const { getFilter } = await import("../../database/utils.js");
|
||||
|
||||
const flushPromises = () => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
const createManager = () =>
|
||||
new SchedulerManager(
|
||||
{ name: "redis" },
|
||||
{ name: "mongo" },
|
||||
{ name: "nats" },
|
||||
);
|
||||
|
||||
const dueAt = new Date("2026-09-06T12:00:00.000Z");
|
||||
|
||||
describe("SchedulerManager", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
workerInstances.length = 0;
|
||||
updateManagerInstances.length = 0;
|
||||
onSchedulerEvent.mockClear();
|
||||
listObjects.mockResolvedValue([]);
|
||||
getObject.mockImplementation(async ({ id }) => ({ _id: id }));
|
||||
});
|
||||
|
||||
describe("start", () => {
|
||||
it("should start the worker, discover scheduled properties, subscribe, and seed schedulers", async () => {
|
||||
const object = { _id: "inv-1", dueAt, remindAt: null };
|
||||
listObjects.mockResolvedValue([object]);
|
||||
|
||||
const manager = createManager();
|
||||
await manager.start();
|
||||
|
||||
expect(workerInstances).toHaveLength(1);
|
||||
expect(manager.scheduledProperties).toEqual([
|
||||
{
|
||||
model: invoiceModelEntry,
|
||||
property: "dueAt",
|
||||
filter: { state: "due|sent|acknowledged" },
|
||||
type: "dateTime",
|
||||
},
|
||||
{
|
||||
model: invoiceModelEntry,
|
||||
property: "remindAt",
|
||||
filter: { state: "due|sent|acknowledged" },
|
||||
type: "dateTime",
|
||||
},
|
||||
]);
|
||||
|
||||
const updateManager = manager.updateManager;
|
||||
expect(updateManager.subscribeToObjectNew).toHaveBeenCalledTimes(1);
|
||||
expect(updateManager.subscribeToObjectDelete).toHaveBeenCalledTimes(1);
|
||||
expect(updateManager.subscribeToAllObjectUpdates).toHaveBeenCalledTimes(1);
|
||||
expect(updateManager.subscribeToObjectNew).toHaveBeenCalledWith(
|
||||
"invoice",
|
||||
{ state: "due|sent|acknowledged" },
|
||||
);
|
||||
|
||||
expect(getFilter).toHaveBeenCalledWith(
|
||||
{ state: "due|sent|acknowledged", dueAt: "TODAY.." },
|
||||
["*"],
|
||||
true,
|
||||
invoiceMongooseModel,
|
||||
);
|
||||
expect(manager.schedulers).toHaveLength(1);
|
||||
expect(manager.schedulers[0]).toMatchObject({
|
||||
objectType: "invoice",
|
||||
object,
|
||||
property: "dueAt",
|
||||
value: dueAt,
|
||||
});
|
||||
expect(workerInstances[0].postMessage).toHaveBeenCalledWith({
|
||||
type: "add",
|
||||
scheduler: manager.schedulers[0].toPayload(),
|
||||
});
|
||||
});
|
||||
|
||||
it("should skip objects that are missing a scheduled property value", async () => {
|
||||
listObjects.mockResolvedValue([{ _id: "inv-1" }]);
|
||||
|
||||
const manager = createManager();
|
||||
await manager.start();
|
||||
|
||||
expect(manager.schedulers).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleWorkerMessage", () => {
|
||||
it("should fire a known scheduler and remove it afterwards", async () => {
|
||||
const manager = createManager();
|
||||
manager.startWorker();
|
||||
const scheduler = new Scheduler({
|
||||
id: "scheduler-1",
|
||||
objectType: "invoice",
|
||||
object: { _id: "inv-1" },
|
||||
property: "dueAt",
|
||||
value: dueAt,
|
||||
model: invoiceMongooseModel,
|
||||
});
|
||||
manager.addScheduler(scheduler);
|
||||
|
||||
manager.handleWorkerMessage({ type: "due", id: "scheduler-1" });
|
||||
await flushPromises();
|
||||
|
||||
expect(onSchedulerEvent).toHaveBeenCalledWith(
|
||||
{ _id: "inv-1" },
|
||||
"dueAt",
|
||||
);
|
||||
expect(manager.schedulers).toHaveLength(0);
|
||||
expect(workerInstances[0].postMessage).toHaveBeenCalledWith({
|
||||
type: "remove",
|
||||
id: "scheduler-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("should ignore unknown schedulers and non-due messages", async () => {
|
||||
const manager = createManager();
|
||||
manager.startWorker();
|
||||
|
||||
manager.handleWorkerMessage({ type: "due", id: "missing" });
|
||||
manager.handleWorkerMessage({ type: "add", id: "scheduler-1" });
|
||||
await flushPromises();
|
||||
|
||||
expect(onSchedulerEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should still remove a scheduler when firing fails", async () => {
|
||||
onSchedulerEvent.mockRejectedValueOnce(new Error("boom"));
|
||||
|
||||
const manager = createManager();
|
||||
manager.startWorker();
|
||||
const scheduler = new Scheduler({
|
||||
id: "scheduler-1",
|
||||
objectType: "invoice",
|
||||
object: { _id: "inv-1" },
|
||||
property: "dueAt",
|
||||
value: dueAt,
|
||||
model: invoiceMongooseModel,
|
||||
});
|
||||
manager.addScheduler(scheduler);
|
||||
|
||||
manager.handleWorkerMessage({ type: "due", id: "scheduler-1" });
|
||||
await flushPromises();
|
||||
|
||||
expect(manager.schedulers).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("upsertScheduler", () => {
|
||||
it("should no-op when the object has no id", () => {
|
||||
const manager = createManager();
|
||||
manager.startWorker();
|
||||
|
||||
manager.upsertScheduler({
|
||||
objectType: "invoice",
|
||||
object: {},
|
||||
property: "dueAt",
|
||||
value: dueAt,
|
||||
});
|
||||
|
||||
expect(manager.schedulers).toHaveLength(0);
|
||||
expect(workerInstances[0].postMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should remove an existing scheduler when the value is cleared", () => {
|
||||
const manager = createManager();
|
||||
manager.startWorker();
|
||||
const scheduler = new Scheduler({
|
||||
id: "scheduler-1",
|
||||
objectType: "invoice",
|
||||
object: { _id: "inv-1" },
|
||||
property: "dueAt",
|
||||
value: dueAt,
|
||||
model: invoiceMongooseModel,
|
||||
});
|
||||
manager.addScheduler(scheduler);
|
||||
workerInstances[0].postMessage.mockClear();
|
||||
|
||||
manager.upsertScheduler({
|
||||
objectType: "invoice",
|
||||
object: { _id: "inv-1" },
|
||||
property: "dueAt",
|
||||
value: null,
|
||||
});
|
||||
|
||||
expect(manager.schedulers).toHaveLength(0);
|
||||
expect(workerInstances[0].postMessage).toHaveBeenCalledWith({
|
||||
type: "remove",
|
||||
id: "scheduler-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("should update an existing scheduler without notifying the worker when the time is unchanged", () => {
|
||||
const manager = createManager();
|
||||
manager.startWorker();
|
||||
const scheduler = new Scheduler({
|
||||
id: "scheduler-1",
|
||||
objectType: "invoice",
|
||||
object: { _id: "inv-1", name: "old" },
|
||||
property: "dueAt",
|
||||
value: dueAt,
|
||||
model: invoiceMongooseModel,
|
||||
});
|
||||
manager.addScheduler(scheduler);
|
||||
workerInstances[0].postMessage.mockClear();
|
||||
|
||||
const nextObject = { _id: "inv-1", name: "new" };
|
||||
manager.upsertScheduler({
|
||||
objectType: "invoice",
|
||||
object: nextObject,
|
||||
property: "dueAt",
|
||||
value: dueAt.toISOString(),
|
||||
});
|
||||
|
||||
expect(manager.schedulers).toHaveLength(1);
|
||||
expect(manager.schedulers[0].object).toBe(nextObject);
|
||||
expect(workerInstances[0].postMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should notify the worker when an existing scheduler time changes", () => {
|
||||
const manager = createManager();
|
||||
manager.startWorker();
|
||||
const scheduler = new Scheduler({
|
||||
id: "scheduler-1",
|
||||
objectType: "invoice",
|
||||
object: { _id: "inv-1" },
|
||||
property: "dueAt",
|
||||
value: dueAt,
|
||||
model: invoiceMongooseModel,
|
||||
});
|
||||
manager.addScheduler(scheduler);
|
||||
workerInstances[0].postMessage.mockClear();
|
||||
|
||||
const nextValue = new Date("2026-09-08T12:00:00.000Z");
|
||||
manager.upsertScheduler({
|
||||
objectType: "invoice",
|
||||
object: { _id: "inv-1" },
|
||||
property: "dueAt",
|
||||
value: nextValue,
|
||||
});
|
||||
|
||||
expect(manager.schedulers[0].value).toEqual(nextValue);
|
||||
expect(workerInstances[0].postMessage).toHaveBeenCalledWith({
|
||||
type: "add",
|
||||
scheduler: manager.schedulers[0].toPayload(),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("findScheduler and removeSchedulersForObject", () => {
|
||||
it("should match schedulers by type, property, and stringified object id", () => {
|
||||
const manager = createManager();
|
||||
const scheduler = new Scheduler({
|
||||
id: "scheduler-1",
|
||||
objectType: "invoice",
|
||||
object: { _id: 123 },
|
||||
property: "dueAt",
|
||||
value: dueAt,
|
||||
model: invoiceMongooseModel,
|
||||
});
|
||||
manager.schedulers.push(scheduler);
|
||||
|
||||
expect(manager.findScheduler("invoice", "123", "dueAt")).toBe(scheduler);
|
||||
expect(manager.findScheduler("invoice", "123", "remindAt")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should remove every scheduler for an object and ignore missing ids", () => {
|
||||
const manager = createManager();
|
||||
manager.startWorker();
|
||||
manager.addScheduler(
|
||||
new Scheduler({
|
||||
id: "scheduler-1",
|
||||
objectType: "invoice",
|
||||
object: { _id: "inv-1" },
|
||||
property: "dueAt",
|
||||
value: dueAt,
|
||||
model: invoiceMongooseModel,
|
||||
}),
|
||||
);
|
||||
manager.addScheduler(
|
||||
new Scheduler({
|
||||
id: "scheduler-2",
|
||||
objectType: "invoice",
|
||||
object: { _id: "inv-1" },
|
||||
property: "remindAt",
|
||||
value: dueAt,
|
||||
model: invoiceMongooseModel,
|
||||
}),
|
||||
);
|
||||
manager.addScheduler(
|
||||
new Scheduler({
|
||||
id: "scheduler-3",
|
||||
objectType: "invoice",
|
||||
object: { _id: "inv-2" },
|
||||
property: "dueAt",
|
||||
value: dueAt,
|
||||
model: invoiceMongooseModel,
|
||||
}),
|
||||
);
|
||||
|
||||
manager.removeSchedulersForObject("invoice", {});
|
||||
expect(manager.schedulers).toHaveLength(3);
|
||||
|
||||
manager.removeSchedulersForObject("invoice", { _id: "inv-1" });
|
||||
expect(manager.schedulers.map((scheduler) => scheduler.id)).toEqual([
|
||||
"scheduler-3",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleObjectEvent", () => {
|
||||
it("should sync schedulers from objectNew and objectUpdate events", async () => {
|
||||
const manager = createManager();
|
||||
manager.startWorker();
|
||||
manager.scheduledProperties = [
|
||||
{
|
||||
model: invoiceModelEntry,
|
||||
property: "dueAt",
|
||||
filter: { state: "due|sent|acknowledged" },
|
||||
type: "dateTime",
|
||||
},
|
||||
];
|
||||
getObject.mockResolvedValue({ _id: "inv-1", dueAt });
|
||||
|
||||
manager.handleObjectEvent("objectNew", {
|
||||
objectType: "invoice",
|
||||
object: { _id: "inv-1" },
|
||||
filter: { state: "due|sent|acknowledged" },
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(getObject).toHaveBeenCalledWith({
|
||||
model: invoiceMongooseModel,
|
||||
id: "inv-1",
|
||||
});
|
||||
expect(manager.schedulers).toHaveLength(1);
|
||||
|
||||
getObject.mockResolvedValue({
|
||||
_id: "inv-1",
|
||||
dueAt: new Date("2026-09-09T12:00:00.000Z"),
|
||||
});
|
||||
manager.handleObjectEvent("objectUpdate", {
|
||||
objectType: "invoice",
|
||||
_id: "inv-1",
|
||||
object: { dueAt: new Date("2026-09-09T12:00:00.000Z") },
|
||||
filter: { state: "due|sent|acknowledged" },
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(manager.schedulers[0].value).toEqual(
|
||||
new Date("2026-09-09T12:00:00.000Z"),
|
||||
);
|
||||
});
|
||||
|
||||
it("should ignore events whose filter does not match a scheduled property", async () => {
|
||||
const manager = createManager();
|
||||
manager.startWorker();
|
||||
manager.scheduledProperties = [
|
||||
{
|
||||
model: invoiceModelEntry,
|
||||
property: "dueAt",
|
||||
filter: { state: "due|sent|acknowledged" },
|
||||
type: "dateTime",
|
||||
},
|
||||
];
|
||||
|
||||
manager.handleObjectEvent("objectNew", {
|
||||
objectType: "invoice",
|
||||
object: { _id: "inv-1" },
|
||||
filter: { state: "draft" },
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(manager.schedulers).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should remove schedulers on objectDelete", () => {
|
||||
const manager = createManager();
|
||||
manager.startWorker();
|
||||
manager.addScheduler(
|
||||
new Scheduler({
|
||||
id: "scheduler-1",
|
||||
objectType: "invoice",
|
||||
object: { _id: "inv-1" },
|
||||
property: "dueAt",
|
||||
value: dueAt,
|
||||
model: invoiceMongooseModel,
|
||||
}),
|
||||
);
|
||||
|
||||
manager.handleObjectEvent("objectDelete", {
|
||||
objectType: "invoice",
|
||||
object: { _id: "inv-1" },
|
||||
});
|
||||
|
||||
expect(manager.schedulers).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stop", () => {
|
||||
it("should remove listeners and terminate the worker", async () => {
|
||||
const manager = createManager();
|
||||
await manager.start();
|
||||
manager.updateSubscriptions.add("invoice:{}");
|
||||
|
||||
await manager.stop();
|
||||
|
||||
expect(manager.updateManager.removeAllListeners).toHaveBeenCalled();
|
||||
expect(manager.updateSubscriptions.size).toBe(0);
|
||||
expect(workerInstances[0].postMessage).toHaveBeenCalledWith({
|
||||
type: "stop",
|
||||
});
|
||||
expect(workerInstances[0].terminate).toHaveBeenCalled();
|
||||
expect(manager.worker).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
115
src/scheduler/__tests__/schedulerworker.test.js
Normal file
115
src/scheduler/__tests__/schedulerworker.test.js
Normal file
@ -0,0 +1,115 @@
|
||||
import { jest } from "@jest/globals";
|
||||
|
||||
jest.useFakeTimers();
|
||||
|
||||
jest.unstable_mockModule("worker_threads", () => ({
|
||||
parentPort: {
|
||||
postMessage: jest.fn(),
|
||||
on: jest.fn(),
|
||||
close: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const { parentPort } = await import("worker_threads");
|
||||
await import("../schedulerworker.js");
|
||||
|
||||
const handleParentMessage = parentPort.on.mock.calls[0][1];
|
||||
|
||||
describe("scheduler worker", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
handleParentMessage({ type: "stop" });
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("should fire due schedulers and remove them immediately", () => {
|
||||
handleParentMessage({
|
||||
type: "add",
|
||||
scheduler: {
|
||||
id: "due-1",
|
||||
objectType: "invoice",
|
||||
property: "dueAt",
|
||||
value: "2020-01-01T00:00:00.000Z",
|
||||
},
|
||||
});
|
||||
|
||||
jest.advanceTimersByTime(10);
|
||||
|
||||
expect(parentPort.postMessage).toHaveBeenCalledWith({
|
||||
type: "due",
|
||||
id: "due-1",
|
||||
objectType: "invoice",
|
||||
property: "dueAt",
|
||||
value: "2020-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
parentPort.postMessage.mockClear();
|
||||
jest.advanceTimersByTime(20);
|
||||
|
||||
expect(parentPort.postMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not fire schedulers that are still in the future", () => {
|
||||
const future = new Date(Date.now() + 60_000).toISOString();
|
||||
|
||||
handleParentMessage({
|
||||
type: "add",
|
||||
scheduler: {
|
||||
id: "future-1",
|
||||
objectType: "invoice",
|
||||
property: "dueAt",
|
||||
value: future,
|
||||
},
|
||||
});
|
||||
|
||||
jest.advanceTimersByTime(20);
|
||||
|
||||
expect(parentPort.postMessage).not.toHaveBeenCalled();
|
||||
|
||||
handleParentMessage({ type: "remove", id: "future-1" });
|
||||
});
|
||||
|
||||
it("should not fire a removed scheduler", () => {
|
||||
handleParentMessage({
|
||||
type: "add",
|
||||
scheduler: {
|
||||
id: "remove-1",
|
||||
objectType: "invoice",
|
||||
property: "dueAt",
|
||||
value: "2020-01-01T00:00:00.000Z",
|
||||
},
|
||||
});
|
||||
handleParentMessage({ type: "remove", id: "remove-1" });
|
||||
|
||||
jest.advanceTimersByTime(10);
|
||||
|
||||
expect(parentPort.postMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should ignore unknown message types", () => {
|
||||
expect(() => handleParentMessage({ type: "unknown" })).not.toThrow();
|
||||
});
|
||||
|
||||
it("should stop polling and close the parent port", () => {
|
||||
handleParentMessage({ type: "stop" });
|
||||
|
||||
expect(parentPort.close).toHaveBeenCalled();
|
||||
|
||||
handleParentMessage({
|
||||
type: "add",
|
||||
scheduler: {
|
||||
id: "after-stop",
|
||||
objectType: "invoice",
|
||||
property: "dueAt",
|
||||
value: "2020-01-01T00:00:00.000Z",
|
||||
},
|
||||
});
|
||||
|
||||
jest.advanceTimersByTime(20);
|
||||
|
||||
expect(parentPort.postMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
53
src/scheduler/scheduler.js
Normal file
53
src/scheduler/scheduler.js
Normal file
@ -0,0 +1,53 @@
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
/**
|
||||
* Represents a single scheduled property change on a model object.
|
||||
*
|
||||
* A Scheduler is created whenever a scheduled property on some object needs
|
||||
* to "fire" once a target date/time has passed (e.g. a `publishAt` field).
|
||||
* The SchedulerManager watches these from a worker thread and, once
|
||||
* `value` is <= the current date/time, calls
|
||||
* `object.onSchedulerEvent(property, value)` back on the main thread.
|
||||
*/
|
||||
export class Scheduler {
|
||||
/**
|
||||
* @param {Object} params
|
||||
* @param {string} [params.id] - Unique id for this scheduler. Generated if omitted.
|
||||
* @param {string} params.objectType - The model/object type (e.g. model.type).
|
||||
* @param {Object} params.object - The live mongoose document/object this scheduler acts on.
|
||||
* @param {string} params.property - The name of the scheduled property.
|
||||
* @param {Date|string} params.value - The date/time the property should fire at.
|
||||
*/
|
||||
constructor({ id, objectType, object, property, value, model }) {
|
||||
console.log("Scheduler constructor", {
|
||||
id,
|
||||
objectType,
|
||||
object,
|
||||
property,
|
||||
value,
|
||||
model,
|
||||
});
|
||||
this.id = id || randomUUID();
|
||||
this.objectType = objectType;
|
||||
this.object = object;
|
||||
this.property = property;
|
||||
this.value = value instanceof Date ? value : new Date(value);
|
||||
this.model = model;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plain, serializable representation of this scheduler.
|
||||
*
|
||||
* Used to hand scheduler data off to the worker thread - the live
|
||||
* `object` (a mongoose document, functions, etc.) cannot cross the
|
||||
* worker_threads boundary, so only the primitive fields are sent.
|
||||
*/
|
||||
toPayload() {
|
||||
return {
|
||||
id: this.id,
|
||||
objectType: this.objectType,
|
||||
property: this.property,
|
||||
value: this.value.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
340
src/scheduler/schedulermanager.js
Normal file
340
src/scheduler/schedulermanager.js
Normal file
@ -0,0 +1,340 @@
|
||||
import { Worker } from "worker_threads";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { models } from "../database/schemas/models.js";
|
||||
import log4js from "log4js";
|
||||
import { loadConfig } from "../config.js";
|
||||
import { listObjects, getObject } from "../database/database.js";
|
||||
import { getFilter } from "../database/utils.js";
|
||||
import { UpdateManager } from "../updates/updatemanager.js";
|
||||
import { Scheduler } from "./scheduler.js";
|
||||
|
||||
const config = loadConfig();
|
||||
const logger = log4js.getLogger("Scheduler Manager");
|
||||
logger.level = config.server.logLevel;
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const filtersEqual = (left, right) =>
|
||||
JSON.stringify(left || {}) === JSON.stringify(right || {});
|
||||
|
||||
export class SchedulerManager {
|
||||
constructor(redisServer, mongoServer, natsServer) {
|
||||
this.redisServer = redisServer;
|
||||
this.mongoServer = mongoServer;
|
||||
this.natsServer = natsServer;
|
||||
this.scheduledProperties = [];
|
||||
this.schedulers = [];
|
||||
this.worker = null;
|
||||
this.updateSubscriptions = new Set();
|
||||
this.updateManager = new UpdateManager({
|
||||
socketId: "scheduler-manager",
|
||||
socket: {
|
||||
emit: (eventName, data) => this.handleObjectEvent(eventName, data),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async start() {
|
||||
logger.info("Starting scheduler manager...");
|
||||
this.startWorker();
|
||||
logger.debug("Getting scheduled properties...");
|
||||
this.populateScheduledProperties();
|
||||
logger.debug("Subscribing to scheduled object updates...");
|
||||
await this.setupUpdateSubscriptions();
|
||||
logger.debug("Setting up schedulers...");
|
||||
await this.setupSchedulers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Spins up the worker thread that runs the polling loop, and wires up
|
||||
* its messages back to this manager. The loop itself lives entirely in
|
||||
* schedulerWorker.js so it never blocks the main event loop.
|
||||
*/
|
||||
startWorker() {
|
||||
this.worker = new Worker(path.join(__dirname, "schedulerWorker.js"));
|
||||
|
||||
this.worker.on("message", (message) => this.handleWorkerMessage(message));
|
||||
|
||||
this.worker.on("error", (error) => {
|
||||
logger.error("Scheduler worker error:", error);
|
||||
});
|
||||
|
||||
this.worker.on("exit", (code) => {
|
||||
if (code !== 0) {
|
||||
logger.error(`Scheduler worker stopped with exit code ${code}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handleWorkerMessage(message) {
|
||||
if (message.type !== "due") return;
|
||||
|
||||
const scheduler = this.schedulers.find((s) => s.id === message.id);
|
||||
if (!scheduler) {
|
||||
logger.warn(`Received due event for unknown scheduler id ${message.id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.fireScheduler(scheduler);
|
||||
}
|
||||
|
||||
async fireScheduler(scheduler) {
|
||||
try {
|
||||
logger.debug(
|
||||
`Firing scheduler ${scheduler.id} for ${scheduler.objectType}.${scheduler.property}`,
|
||||
);
|
||||
await scheduler.model.onSchedulerEvent(
|
||||
scheduler.object,
|
||||
scheduler.property,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Error firing scheduler ${scheduler.id} for ${scheduler.objectType}.${scheduler.property}:`,
|
||||
error,
|
||||
);
|
||||
} finally {
|
||||
this.removeScheduler(scheduler.id);
|
||||
}
|
||||
}
|
||||
|
||||
addScheduler(scheduler) {
|
||||
this.schedulers.push(scheduler);
|
||||
this.worker?.postMessage({ type: "add", scheduler: scheduler.toPayload() });
|
||||
}
|
||||
|
||||
removeScheduler(id) {
|
||||
this.schedulers = this.schedulers.filter((s) => s.id !== id);
|
||||
this.worker?.postMessage({ type: "remove", id });
|
||||
}
|
||||
|
||||
findScheduler(objectType, objectId, property) {
|
||||
return this.schedulers.find(
|
||||
(scheduler) =>
|
||||
scheduler.objectType === objectType &&
|
||||
scheduler.property === property &&
|
||||
String(scheduler.object?._id) === String(objectId),
|
||||
);
|
||||
}
|
||||
|
||||
getScheduledPropertiesForEvent(objectType, filter) {
|
||||
return this.scheduledProperties.filter(
|
||||
(scheduledProperty) =>
|
||||
scheduledProperty.model.type === objectType &&
|
||||
filtersEqual(scheduledProperty.filter, filter),
|
||||
);
|
||||
}
|
||||
|
||||
upsertScheduler({ objectType, object, property, value }) {
|
||||
const objectId = object?._id;
|
||||
if (objectId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug("Upserting scheduler for", {
|
||||
objectType,
|
||||
objectId,
|
||||
property,
|
||||
value,
|
||||
});
|
||||
const existing = this.findScheduler(objectType, objectId, property);
|
||||
if (!value) {
|
||||
if (existing) {
|
||||
this.removeScheduler(existing.id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
logger.debug("Existing scheduler found", { existing });
|
||||
const nextValue = value instanceof Date ? value : new Date(value);
|
||||
existing.object = object;
|
||||
if (existing.value.getTime() !== nextValue.getTime()) {
|
||||
existing.value = nextValue;
|
||||
this.worker?.postMessage({
|
||||
type: "add",
|
||||
scheduler: existing.toPayload(),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const model = Object.values(models).find(
|
||||
(m) => m.type === objectType,
|
||||
).model;
|
||||
|
||||
logger.debug("Creating new scheduler", {
|
||||
objectType,
|
||||
objectId,
|
||||
property,
|
||||
value,
|
||||
});
|
||||
|
||||
const scheduler = new Scheduler({
|
||||
objectType,
|
||||
object,
|
||||
property,
|
||||
value,
|
||||
model,
|
||||
});
|
||||
this.addScheduler(scheduler);
|
||||
}
|
||||
|
||||
removeSchedulersForObject(objectType, object) {
|
||||
const objectId = object?._id;
|
||||
if (objectId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const schedulersToRemove = this.schedulers.filter(
|
||||
(scheduler) =>
|
||||
scheduler.objectType === objectType &&
|
||||
String(scheduler.object?._id) === String(objectId),
|
||||
);
|
||||
for (const scheduler of schedulersToRemove) {
|
||||
this.removeScheduler(scheduler.id);
|
||||
}
|
||||
}
|
||||
|
||||
async syncSchedulersForObject(objectType, object, filter) {
|
||||
const model = Object.values(models).find(
|
||||
(m) => m.type === objectType,
|
||||
).model;
|
||||
const retrievedObject = await getObject({
|
||||
model,
|
||||
id: object._id,
|
||||
});
|
||||
for (const scheduledProperty of this.getScheduledPropertiesForEvent(
|
||||
objectType,
|
||||
filter,
|
||||
)) {
|
||||
this.upsertScheduler({
|
||||
objectType,
|
||||
object: retrievedObject,
|
||||
property: scheduledProperty.property,
|
||||
value: retrievedObject?.[scheduledProperty.property],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleObjectEvent(eventName, data) {
|
||||
if (eventName === "objectNew") {
|
||||
logger.debug(
|
||||
`Received objectNew for ${data.objectType} ${data.object?._id}`,
|
||||
);
|
||||
this.syncSchedulersForObject(data.objectType, data.object, data.filter);
|
||||
return;
|
||||
}
|
||||
|
||||
if (eventName === "objectDelete") {
|
||||
logger.debug(
|
||||
`Received objectDelete for ${data.objectType} ${data.object?._id}`,
|
||||
);
|
||||
this.removeSchedulersForObject(data.objectType, data.object);
|
||||
return;
|
||||
}
|
||||
|
||||
if (eventName === "objectUpdate") {
|
||||
const object = { _id: data._id, ...data.object };
|
||||
logger.debug(`Received objectUpdate for ${data.objectType} ${data._id}`);
|
||||
this.syncSchedulersForObject(data.objectType, object, data.filter);
|
||||
}
|
||||
}
|
||||
|
||||
async setupUpdateSubscriptions() {
|
||||
for (const scheduledProperty of this.scheduledProperties) {
|
||||
const objectType = scheduledProperty.model.type;
|
||||
const filter = scheduledProperty.filter || {};
|
||||
const subscriptionKey = `${objectType}:${JSON.stringify(filter)}`;
|
||||
if (this.updateSubscriptions.has(subscriptionKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`Subscribing to ${objectType} updates with filter ${JSON.stringify(filter)}`,
|
||||
);
|
||||
await this.updateManager.subscribeToObjectNew(objectType, filter);
|
||||
await this.updateManager.subscribeToObjectDelete(objectType, filter);
|
||||
await this.updateManager.subscribeToAllObjectUpdates(objectType, filter);
|
||||
this.updateSubscriptions.add(subscriptionKey);
|
||||
}
|
||||
}
|
||||
|
||||
populateScheduledProperties() {
|
||||
for (const model of Object.values(models)) {
|
||||
const modelLabel = model.label;
|
||||
const modelName = model.type;
|
||||
const mongooseModel = model.model;
|
||||
logger.trace(`Checking model: ${modelLabel} for scheduled properties...`);
|
||||
const scheduledProperties = mongooseModel?.scheduledProperties || [];
|
||||
for (const scheduledProperty of scheduledProperties) {
|
||||
const property = scheduledProperty.name;
|
||||
const filter = scheduledProperty.filter;
|
||||
const type = scheduledProperty.type;
|
||||
logger.debug(
|
||||
`Found scheduled property: ${modelName}.${property} of type ${type} with filter ${JSON.stringify(filter)}`,
|
||||
);
|
||||
this.scheduledProperties.push({
|
||||
model,
|
||||
property,
|
||||
filter,
|
||||
type,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async setupSchedulers() {
|
||||
for (const scheduledProperty of this.scheduledProperties) {
|
||||
const model = scheduledProperty.model;
|
||||
const mongooseModel = model.model;
|
||||
const property = scheduledProperty.property;
|
||||
const filter = { ...scheduledProperty.filter, [property]: "TODAY.." };
|
||||
const type = scheduledProperty.type;
|
||||
logger.debug(
|
||||
`Setting up scheduler for: ${model.type}.${property} of type ${type} with filter ${JSON.stringify(filter)}`,
|
||||
);
|
||||
const retrievedFilter = await getFilter(
|
||||
filter,
|
||||
["*"],
|
||||
true,
|
||||
mongooseModel,
|
||||
);
|
||||
const objects = await listObjects({
|
||||
model: mongooseModel,
|
||||
filter: retrievedFilter,
|
||||
});
|
||||
for (const object of objects) {
|
||||
const propertyValue = object[property];
|
||||
if (!propertyValue) {
|
||||
logger.warn(
|
||||
`Object ${object._id} of type ${model.type} has no value for scheduled property ${property}, skipping.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
this.upsertScheduler({
|
||||
objectType: model.type,
|
||||
object,
|
||||
property,
|
||||
value: propertyValue,
|
||||
});
|
||||
logger.debug(
|
||||
`Created scheduler for ${model.type}.${property} on ${object._id}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async stop() {
|
||||
logger.info("Stopping scheduler manager...");
|
||||
await this.updateManager.removeAllListeners();
|
||||
this.updateSubscriptions.clear();
|
||||
if (this.worker) {
|
||||
this.worker.postMessage({ type: "stop" });
|
||||
await this.worker.terminate();
|
||||
this.worker = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
50
src/scheduler/schedulerworker.js
Normal file
50
src/scheduler/schedulerworker.js
Normal file
@ -0,0 +1,50 @@
|
||||
import { parentPort } from "worker_threads";
|
||||
|
||||
// How often to check whether any scheduler's value has passed.
|
||||
const POLL_INTERVAL_MS = 10;
|
||||
|
||||
// id -> { objectType, property, value: Date }
|
||||
// Only plain, serializable data lives here - the real mongoose objects
|
||||
// stay on the main thread since they can't cross the worker boundary.
|
||||
const schedulers = new Map();
|
||||
|
||||
function checkSchedulers() {
|
||||
const now = Date.now();
|
||||
for (const [id, scheduler] of schedulers) {
|
||||
if (scheduler.value.getTime() <= now) {
|
||||
// Remove immediately so we don't fire the same scheduler twice
|
||||
// while waiting for the main thread to process/remove it.
|
||||
schedulers.delete(id);
|
||||
parentPort.postMessage({
|
||||
type: "due",
|
||||
id,
|
||||
objectType: scheduler.objectType,
|
||||
property: scheduler.property,
|
||||
value: scheduler.value.toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let intervalHandle = setInterval(checkSchedulers, POLL_INTERVAL_MS);
|
||||
|
||||
parentPort.on("message", (message) => {
|
||||
switch (message.type) {
|
||||
case "add":
|
||||
schedulers.set(message.scheduler.id, {
|
||||
objectType: message.scheduler.objectType,
|
||||
property: message.scheduler.property,
|
||||
value: new Date(message.scheduler.value),
|
||||
});
|
||||
break;
|
||||
case "remove":
|
||||
schedulers.delete(message.id);
|
||||
break;
|
||||
case "stop":
|
||||
clearInterval(intervalHandle);
|
||||
parentPort.close();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
336
src/updates/__tests__/updatemanager.test.js
Normal file
336
src/updates/__tests__/updatemanager.test.js
Normal file
@ -0,0 +1,336 @@
|
||||
import { jest } from "@jest/globals";
|
||||
|
||||
jest.unstable_mockModule("../../database/nats.js", () => ({
|
||||
natsServer: {
|
||||
subscribe: jest.fn().mockResolvedValue({ success: true }),
|
||||
removeSubscription: jest.fn().mockResolvedValue({ success: true }),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule("log4js", () => ({
|
||||
default: {
|
||||
getLogger: () => ({
|
||||
level: "info",
|
||||
debug: jest.fn(),
|
||||
error: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
trace: jest.fn(),
|
||||
info: jest.fn(),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule("../../config.js", () => ({
|
||||
loadConfig: jest.fn(() => ({
|
||||
server: {
|
||||
logLevel: "info",
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule("../../database/filter.js", () => ({
|
||||
getFilter: jest.fn(async (query) => query),
|
||||
getObjectTypeModel: jest.fn(async () => null),
|
||||
}));
|
||||
|
||||
const { UpdateManager } = await import("../updatemanager.js");
|
||||
const { natsServer } = await import("../../database/nats.js");
|
||||
const { getFilter } = await import("../../database/filter.js");
|
||||
|
||||
describe("UpdateManager", () => {
|
||||
let mockSocketClient;
|
||||
let updateManager;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
mockSocketClient = {
|
||||
socketId: "test-socket-id",
|
||||
socket: {
|
||||
emit: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
updateManager = new UpdateManager(mockSocketClient);
|
||||
});
|
||||
|
||||
describe("subscribeToObjectNew", () => {
|
||||
it("should subscribe to new object events and emit", async () => {
|
||||
await updateManager.subscribeToObjectNew("printer");
|
||||
|
||||
expect(natsServer.subscribe).toHaveBeenCalledWith(
|
||||
"printers.new",
|
||||
"test-socket-id:{}",
|
||||
expect.any(Function),
|
||||
);
|
||||
|
||||
const natsCallback = natsServer.subscribe.mock.calls[0][2];
|
||||
const data = { name: "New Printer" };
|
||||
await natsCallback("printers.new", data);
|
||||
|
||||
expect(mockSocketClient.socket.emit).toHaveBeenCalledWith("objectNew", {
|
||||
object: data,
|
||||
objectType: "printer",
|
||||
filter: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("should emit filtered new object events when the list filter matches", async () => {
|
||||
const filter = { "state.type": "ready" };
|
||||
const data = {
|
||||
_id: "123",
|
||||
name: "New Printer",
|
||||
state: { type: "ready" },
|
||||
};
|
||||
|
||||
await updateManager.subscribeToObjectNew("printer", filter);
|
||||
|
||||
expect(getFilter).toHaveBeenCalledWith(
|
||||
filter,
|
||||
Object.keys(filter),
|
||||
true,
|
||||
null,
|
||||
);
|
||||
expect(natsServer.subscribe).toHaveBeenCalledWith(
|
||||
"printers.new",
|
||||
'test-socket-id:{"state.type":"ready"}',
|
||||
expect.any(Function),
|
||||
);
|
||||
|
||||
const natsCallback = natsServer.subscribe.mock.calls[0][2];
|
||||
await natsCallback("printers.new", data);
|
||||
|
||||
expect(mockSocketClient.socket.emit).toHaveBeenCalledWith("objectNew", {
|
||||
object: data,
|
||||
objectType: "printer",
|
||||
filter,
|
||||
});
|
||||
});
|
||||
|
||||
it("should skip filtered new object events when the list filter misses", async () => {
|
||||
const filter = { "state.type": "ready" };
|
||||
|
||||
await updateManager.subscribeToObjectNew("printer", filter);
|
||||
|
||||
const natsCallback = natsServer.subscribe.mock.calls[0][2];
|
||||
await natsCallback("printers.new", { _id: "123" });
|
||||
|
||||
expect(mockSocketClient.socket.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should match reference filters when the id is populated or flat", async () => {
|
||||
const filter = { "parent._id": "parent-id" };
|
||||
|
||||
await updateManager.subscribeToObjectNew("note", filter);
|
||||
const natsCallback = natsServer.subscribe.mock.calls[0][2];
|
||||
|
||||
await natsCallback("notes.new", {
|
||||
_id: "note-1",
|
||||
parent: { _id: "parent-id" },
|
||||
});
|
||||
expect(mockSocketClient.socket.emit).toHaveBeenCalledTimes(1);
|
||||
|
||||
mockSocketClient.socket.emit.mockClear();
|
||||
|
||||
await natsCallback("notes.new", {
|
||||
_id: "note-2",
|
||||
parent: "parent-id",
|
||||
});
|
||||
expect(mockSocketClient.socket.emit).toHaveBeenCalledTimes(1);
|
||||
|
||||
mockSocketClient.socket.emit.mockClear();
|
||||
|
||||
await natsCallback("notes.new", {
|
||||
_id: "note-3",
|
||||
parent: "other-parent",
|
||||
});
|
||||
expect(mockSocketClient.socket.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("subscribeToObjectDelete", () => {
|
||||
it("should subscribe to delete events and emit", async () => {
|
||||
await updateManager.subscribeToObjectDelete("printer");
|
||||
|
||||
expect(natsServer.subscribe).toHaveBeenCalledWith(
|
||||
"printers.delete",
|
||||
"test-socket-id:{}",
|
||||
expect.any(Function),
|
||||
);
|
||||
|
||||
const natsCallback = natsServer.subscribe.mock.calls[0][2];
|
||||
const data = { _id: "123" };
|
||||
await natsCallback("printers.delete", data);
|
||||
|
||||
expect(mockSocketClient.socket.emit).toHaveBeenCalledWith(
|
||||
"objectDelete",
|
||||
{
|
||||
object: data,
|
||||
objectType: "printer",
|
||||
filter: {},
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("subscribeToObjectUpdate", () => {
|
||||
it("should subscribe to update events for specific object", async () => {
|
||||
await updateManager.subscribeToObjectUpdate("123", "printer");
|
||||
|
||||
expect(natsServer.subscribe).toHaveBeenCalledWith(
|
||||
"printers.123.object",
|
||||
"test-socket-id",
|
||||
expect.any(Function),
|
||||
);
|
||||
|
||||
const natsCallback = natsServer.subscribe.mock.calls[0][2];
|
||||
const data = { _id: "123", status: "idle" };
|
||||
natsCallback("printers.123.object", data);
|
||||
|
||||
expect(mockSocketClient.socket.emit).toHaveBeenCalledWith(
|
||||
"objectUpdate",
|
||||
{
|
||||
_id: "123",
|
||||
objectType: "printer",
|
||||
object: { status: "idle" },
|
||||
filter: {},
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("subscribeToAllObjectUpdates", () => {
|
||||
it("should subscribe to all update events for an object type", async () => {
|
||||
await updateManager.subscribeToAllObjectUpdates("printer");
|
||||
|
||||
expect(natsServer.subscribe).toHaveBeenCalledWith(
|
||||
"printers.*.object",
|
||||
"test-socket-id:{}",
|
||||
expect.any(Function),
|
||||
);
|
||||
|
||||
const natsCallback = natsServer.subscribe.mock.calls[0][2];
|
||||
const data = { _id: "456", status: "idle" };
|
||||
natsCallback("printers.456.object", data);
|
||||
|
||||
expect(mockSocketClient.socket.emit).toHaveBeenCalledWith(
|
||||
"objectUpdate",
|
||||
{
|
||||
_id: "456",
|
||||
objectType: "printer",
|
||||
object: { status: "idle" },
|
||||
filter: {},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("should emit filtered update events when the list filter matches", async () => {
|
||||
const filter = { "state.type": "ready" };
|
||||
const data = {
|
||||
_id: "456",
|
||||
status: "idle",
|
||||
state: { type: "ready" },
|
||||
};
|
||||
|
||||
await updateManager.subscribeToAllObjectUpdates("printer", filter);
|
||||
|
||||
expect(getFilter).toHaveBeenCalledWith(
|
||||
filter,
|
||||
Object.keys(filter),
|
||||
true,
|
||||
null,
|
||||
);
|
||||
expect(natsServer.subscribe).toHaveBeenCalledWith(
|
||||
"printers.*.object",
|
||||
'test-socket-id:{"state.type":"ready"}',
|
||||
expect.any(Function),
|
||||
);
|
||||
|
||||
const natsCallback = natsServer.subscribe.mock.calls[0][2];
|
||||
natsCallback("printers.456.object", data);
|
||||
|
||||
expect(mockSocketClient.socket.emit).toHaveBeenCalledWith(
|
||||
"objectUpdate",
|
||||
{
|
||||
_id: "456",
|
||||
objectType: "printer",
|
||||
object: { status: "idle", state: { type: "ready" } },
|
||||
filter,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("should skip filtered update events when the list filter misses", async () => {
|
||||
const filter = { "state.type": "ready" };
|
||||
|
||||
await updateManager.subscribeToAllObjectUpdates("printer", filter);
|
||||
|
||||
const natsCallback = natsServer.subscribe.mock.calls[0][2];
|
||||
natsCallback("printers.456.object", { _id: "456", status: "idle" });
|
||||
|
||||
expect(mockSocketClient.socket.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should skip all-object updates when a specific subscription exists", async () => {
|
||||
await updateManager.subscribeToObjectUpdate("123", "printer");
|
||||
await updateManager.subscribeToAllObjectUpdates("printer");
|
||||
|
||||
const allUpdatesCallback = natsServer.subscribe.mock.calls[1][2];
|
||||
allUpdatesCallback("printers.123.object", {
|
||||
_id: "123",
|
||||
status: "idle",
|
||||
});
|
||||
|
||||
expect(mockSocketClient.socket.emit).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("remove methods", () => {
|
||||
it("should remove new listener", async () => {
|
||||
await updateManager.removeObjectNewListener("printer");
|
||||
expect(natsServer.removeSubscription).toHaveBeenCalledWith(
|
||||
"printers.new",
|
||||
"test-socket-id:{}",
|
||||
);
|
||||
});
|
||||
|
||||
it("should remove delete listener", async () => {
|
||||
await updateManager.removeObjectDeleteListener("printer");
|
||||
expect(natsServer.removeSubscription).toHaveBeenCalledWith(
|
||||
"printers.delete",
|
||||
"test-socket-id:{}",
|
||||
);
|
||||
});
|
||||
|
||||
it("should remove update listener", async () => {
|
||||
await updateManager.removeObjectUpdateListener("123", "printer");
|
||||
expect(natsServer.removeSubscription).toHaveBeenCalledWith(
|
||||
"printers.123.object",
|
||||
"test-socket-id",
|
||||
);
|
||||
});
|
||||
|
||||
it("should remove all object updates listener", async () => {
|
||||
await updateManager.removeAllObjectUpdatesListener("printer");
|
||||
expect(natsServer.removeSubscription).toHaveBeenCalledWith(
|
||||
"printers.*.object",
|
||||
"test-socket-id:{}",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeAllListeners", () => {
|
||||
it("should remove all subscriptions", async () => {
|
||||
await updateManager.subscribeToObjectNew("printer");
|
||||
await updateManager.subscribeToObjectDelete("printer");
|
||||
await updateManager.subscribeToObjectUpdate("123", "printer");
|
||||
|
||||
expect(updateManager.subscriptions.size).toBe(3);
|
||||
|
||||
await updateManager.removeAllListeners();
|
||||
|
||||
expect(natsServer.removeSubscription).toHaveBeenCalledTimes(3);
|
||||
expect(updateManager.subscriptions.size).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
448
src/updates/updatemanager.js
Normal file
448
src/updates/updatemanager.js
Normal file
@ -0,0 +1,448 @@
|
||||
import log4js from "log4js";
|
||||
import _ from "lodash";
|
||||
import { loadConfig } from "../config.js";
|
||||
import { natsServer } from "../database/nats.js";
|
||||
import { expandObjectIds } from "../database/utils.js";
|
||||
import { getFilter, getObjectTypeModel } from "../database/filter.js";
|
||||
import { formatTraceData } from "../utils.js";
|
||||
const config = loadConfig();
|
||||
|
||||
// Setup logger
|
||||
const logger = log4js.getLogger("Update Manager");
|
||||
logger.level = config.server.logLevel;
|
||||
|
||||
const normalizeFilter = (filter) =>
|
||||
filter && typeof filter === "object" && !Array.isArray(filter) ? filter : {};
|
||||
|
||||
const unwrapId = (value) => {
|
||||
if (
|
||||
value &&
|
||||
typeof value === "object" &&
|
||||
!Array.isArray(value) &&
|
||||
value._id != null
|
||||
) {
|
||||
return value._id;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const getFilterValue = (object, key) => {
|
||||
if (key.endsWith("._id")) {
|
||||
const refPath = key.slice(0, -4);
|
||||
const ref = _.get(object, refPath);
|
||||
if (ref && typeof ref === "object" && ref._id) {
|
||||
return ref._id;
|
||||
}
|
||||
return ref;
|
||||
}
|
||||
|
||||
return unwrapId(_.get(object, key));
|
||||
};
|
||||
|
||||
const valuesMatch = (actual, expected) => {
|
||||
const left = unwrapId(actual);
|
||||
const right = unwrapId(expected);
|
||||
|
||||
if (left == right) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (left != null && right != null) {
|
||||
return String(left) === String(right);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const isOperatorObject = (value) => {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return false;
|
||||
}
|
||||
if (value instanceof Date || value._bsontype === "ObjectId") {
|
||||
return false;
|
||||
}
|
||||
const keys = Object.keys(value);
|
||||
return keys.length > 0 && keys.every((key) => key.startsWith("$"));
|
||||
};
|
||||
|
||||
const compareValues = (actual, expected, operator) => {
|
||||
if (actual == null || expected == null) {
|
||||
return false;
|
||||
}
|
||||
if (operator === "$gt") return actual > expected;
|
||||
if (operator === "$gte") return actual >= expected;
|
||||
if (operator === "$lt") return actual < expected;
|
||||
if (operator === "$lte") return actual <= expected;
|
||||
return false;
|
||||
};
|
||||
|
||||
const matchesOperators = (actual, operators) => {
|
||||
if (Object.prototype.hasOwnProperty.call(operators, "$eq")) {
|
||||
if (!valuesMatch(actual, operators.$eq)) return false;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(operators, "$ne")) {
|
||||
if (valuesMatch(actual, operators.$ne)) return false;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(operators, "$in")) {
|
||||
if (
|
||||
!Array.isArray(operators.$in) ||
|
||||
!operators.$in.some((value) => valuesMatch(actual, value))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(operators, "$nin")) {
|
||||
if (
|
||||
Array.isArray(operators.$nin) &&
|
||||
operators.$nin.some((value) => valuesMatch(actual, value))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(operators, "$regex")) {
|
||||
const regex = new RegExp(operators.$regex, operators.$options || "");
|
||||
if (!regex.test(String(actual ?? ""))) return false;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(operators, "$not")) {
|
||||
if (matchesExpected(actual, operators.$not)) return false;
|
||||
}
|
||||
for (const operator of ["$gt", "$gte", "$lt", "$lte"]) {
|
||||
if (
|
||||
Object.prototype.hasOwnProperty.call(operators, operator) &&
|
||||
!compareValues(actual, operators[operator], operator)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const matchesExpected = (actual, expected) => {
|
||||
if (isOperatorObject(expected)) {
|
||||
return matchesOperators(actual, expected);
|
||||
}
|
||||
return valuesMatch(actual, expected);
|
||||
};
|
||||
|
||||
const matchesFilter = (object, filter) => {
|
||||
if (!filter || Object.keys(filter).length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (object == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedObject =
|
||||
typeof object === "object" && !Array.isArray(object)
|
||||
? object
|
||||
: { _id: object };
|
||||
|
||||
if (Array.isArray(filter.$and)) {
|
||||
return filter.$and.every((clause) =>
|
||||
matchesFilter(normalizedObject, clause),
|
||||
);
|
||||
}
|
||||
if (Array.isArray(filter.$or)) {
|
||||
return filter.$or.some((clause) => matchesFilter(normalizedObject, clause));
|
||||
}
|
||||
|
||||
for (const [key, expectedValue] of Object.entries(filter)) {
|
||||
if (key === "$and" || key === "$or") {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
!matchesExpected(getFilterValue(normalizedObject, key), expectedValue)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const resolveObjectTypeFilter = async (objectType, filter = {}) => {
|
||||
const normalizedFilter = normalizeFilter(filter);
|
||||
if (Object.keys(normalizedFilter).length === 0) {
|
||||
return { normalizedFilter, processedFilter: normalizedFilter };
|
||||
}
|
||||
|
||||
const model = await getObjectTypeModel(objectType);
|
||||
const processedFilter = await getFilter(
|
||||
normalizedFilter,
|
||||
Object.keys(normalizedFilter),
|
||||
true,
|
||||
model,
|
||||
);
|
||||
return { normalizedFilter, processedFilter };
|
||||
};
|
||||
|
||||
const stableStringify = (value) => {
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map(stableStringify).join(",")}]`;
|
||||
}
|
||||
|
||||
if (value && typeof value === "object") {
|
||||
return `{${Object.keys(value)
|
||||
.sort()
|
||||
.map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`)
|
||||
.join(",")}}`;
|
||||
}
|
||||
|
||||
return JSON.stringify(value);
|
||||
};
|
||||
|
||||
const getSubscriptionOwner = (socketId, filter) =>
|
||||
`${socketId}:${stableStringify(normalizeFilter(filter))}`;
|
||||
|
||||
const getSubscriptionKey = (subject, owner) => `${subject}:${owner}`;
|
||||
|
||||
/**
|
||||
* UpdateManager handles tracking object updates and broadcasts update events via websockets.
|
||||
*/
|
||||
export class UpdateManager {
|
||||
constructor(socketClient) {
|
||||
this.socketClient = socketClient;
|
||||
this.subscriptions = new Set();
|
||||
this.objectUpdateSubscriptions = new Set();
|
||||
}
|
||||
|
||||
getObjectUpdateSubscriptionKey(objectType, id) {
|
||||
return `${objectType}:${id}`;
|
||||
}
|
||||
|
||||
matchesObjectTypeFilter(objectType, filter, value) {
|
||||
return matchesFilter(value, normalizeFilter(filter));
|
||||
}
|
||||
|
||||
emitObjectTypeEvent(
|
||||
eventName,
|
||||
objectType,
|
||||
filter,
|
||||
value,
|
||||
matchFilter = filter,
|
||||
) {
|
||||
const normalizedFilter = normalizeFilter(filter);
|
||||
const matches = this.matchesObjectTypeFilter(
|
||||
objectType,
|
||||
matchFilter,
|
||||
value,
|
||||
);
|
||||
|
||||
if (!matches) {
|
||||
logger.trace(
|
||||
`Filtered ${eventName} event: ${formatTraceData({
|
||||
objectType,
|
||||
filter: normalizedFilter,
|
||||
value,
|
||||
})}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.socketClient.socket.emit(eventName, {
|
||||
object: value,
|
||||
objectType: objectType,
|
||||
filter: normalizedFilter,
|
||||
});
|
||||
}
|
||||
|
||||
async subscribeToObjectNew(objectType, filter = {}) {
|
||||
const { normalizedFilter, processedFilter } = await resolveObjectTypeFilter(
|
||||
objectType,
|
||||
filter,
|
||||
);
|
||||
const subject = `${objectType}s.new`;
|
||||
const owner = getSubscriptionOwner(
|
||||
this.socketClient.socketId,
|
||||
normalizedFilter,
|
||||
);
|
||||
|
||||
await natsServer.subscribe(subject, owner, async (key, value) => {
|
||||
logger.trace(`Object new event: ${formatTraceData(value)}`);
|
||||
this.emitObjectTypeEvent(
|
||||
"objectNew",
|
||||
objectType,
|
||||
normalizedFilter,
|
||||
value,
|
||||
processedFilter,
|
||||
);
|
||||
});
|
||||
|
||||
this.subscriptions.add(getSubscriptionKey(subject, owner));
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async subscribeToObjectDelete(objectType, filter = {}) {
|
||||
const { normalizedFilter, processedFilter } = await resolveObjectTypeFilter(
|
||||
objectType,
|
||||
filter,
|
||||
);
|
||||
const subject = `${objectType}s.delete`;
|
||||
const owner = getSubscriptionOwner(
|
||||
this.socketClient.socketId,
|
||||
normalizedFilter,
|
||||
);
|
||||
|
||||
await natsServer.subscribe(subject, owner, async (key, value) => {
|
||||
logger.trace(`Object delete event: ${formatTraceData(value)}`);
|
||||
this.emitObjectTypeEvent(
|
||||
"objectDelete",
|
||||
objectType,
|
||||
normalizedFilter,
|
||||
value,
|
||||
processedFilter,
|
||||
);
|
||||
});
|
||||
|
||||
this.subscriptions.add(getSubscriptionKey(subject, owner));
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async subscribeToObjectUpdate(id, objectType) {
|
||||
logger.debug("Subscribing to object update...", id, objectType);
|
||||
const subject = `${objectType}s.${id}.object`;
|
||||
const owner = this.socketClient.socketId;
|
||||
|
||||
await natsServer.subscribe(subject, owner, (key, value) => {
|
||||
this.emitObjectUpdate(objectType, value);
|
||||
});
|
||||
|
||||
this.objectUpdateSubscriptions.add(
|
||||
this.getObjectUpdateSubscriptionKey(objectType, id),
|
||||
);
|
||||
this.subscriptions.add(getSubscriptionKey(subject, owner));
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
emitObjectUpdate(objectType, value, filter = {}, matchFilter = filter) {
|
||||
const expandedValue = expandObjectIds(value);
|
||||
if (!expandedValue || expandedValue._id == null) {
|
||||
logger.warn("Object update missing _id:", objectType, value);
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedFilter = normalizeFilter(filter);
|
||||
const matches = this.matchesObjectTypeFilter(
|
||||
objectType,
|
||||
matchFilter,
|
||||
expandedValue,
|
||||
);
|
||||
|
||||
if (!matches) {
|
||||
logger.trace(
|
||||
`Filtered objectUpdate event: ${formatTraceData({
|
||||
objectType,
|
||||
filter: normalizedFilter,
|
||||
value: expandedValue,
|
||||
})}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const { _id, ...object } = { ...expandedValue };
|
||||
logger.trace("Object update event:", _id, objectType);
|
||||
this.socketClient.socket.emit("objectUpdate", {
|
||||
_id,
|
||||
objectType,
|
||||
object,
|
||||
filter: normalizedFilter,
|
||||
});
|
||||
}
|
||||
|
||||
async subscribeToAllObjectUpdates(objectType, filter = {}) {
|
||||
logger.debug("Subscribing to all object updates...", objectType);
|
||||
const { normalizedFilter, processedFilter } = await resolveObjectTypeFilter(
|
||||
objectType,
|
||||
filter,
|
||||
);
|
||||
const subject = `${objectType}s.*.object`;
|
||||
const owner = getSubscriptionOwner(
|
||||
this.socketClient.socketId,
|
||||
normalizedFilter,
|
||||
);
|
||||
|
||||
await natsServer.subscribe(subject, owner, (key, value) => {
|
||||
const id = value?._id;
|
||||
if (id == null) {
|
||||
logger.warn("Object update missing _id:", objectType, value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
this.objectUpdateSubscriptions.has(
|
||||
this.getObjectUpdateSubscriptionKey(objectType, id),
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.emitObjectUpdate(
|
||||
objectType,
|
||||
value,
|
||||
normalizedFilter,
|
||||
processedFilter,
|
||||
);
|
||||
});
|
||||
|
||||
this.subscriptions.add(getSubscriptionKey(subject, owner));
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async removeObjectNewListener(objectType, filter = {}) {
|
||||
const subject = `${objectType}s.new`;
|
||||
const owner = getSubscriptionOwner(this.socketClient.socketId, filter);
|
||||
|
||||
await natsServer.removeSubscription(subject, owner);
|
||||
this.subscriptions.delete(getSubscriptionKey(subject, owner));
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async removeObjectDeleteListener(objectType, filter = {}) {
|
||||
const subject = `${objectType}s.delete`;
|
||||
const owner = getSubscriptionOwner(this.socketClient.socketId, filter);
|
||||
|
||||
await natsServer.removeSubscription(subject, owner);
|
||||
this.subscriptions.delete(getSubscriptionKey(subject, owner));
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async removeObjectUpdateListener(id, objectType) {
|
||||
const subject = `${objectType}s.${id}.object`;
|
||||
const owner = this.socketClient.socketId;
|
||||
|
||||
await natsServer.removeSubscription(subject, owner);
|
||||
this.objectUpdateSubscriptions.delete(
|
||||
this.getObjectUpdateSubscriptionKey(objectType, id),
|
||||
);
|
||||
this.subscriptions.delete(getSubscriptionKey(subject, owner));
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async removeAllObjectUpdatesListener(objectType, filter = {}) {
|
||||
const subject = `${objectType}s.*.object`;
|
||||
const owner = getSubscriptionOwner(this.socketClient.socketId, filter);
|
||||
|
||||
await natsServer.removeSubscription(subject, owner);
|
||||
this.subscriptions.delete(getSubscriptionKey(subject, owner));
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async removeAllListeners() {
|
||||
logger.debug("Removing all update listeners...");
|
||||
const removePromises = Array.from(this.subscriptions).map(
|
||||
(subscriptionKey) => {
|
||||
const separatorIndex = subscriptionKey.indexOf(":");
|
||||
const subject = subscriptionKey.slice(0, separatorIndex);
|
||||
const owner = subscriptionKey.slice(separatorIndex + 1);
|
||||
return natsServer.removeSubscription(subject, owner);
|
||||
},
|
||||
);
|
||||
|
||||
await Promise.all(removePromises);
|
||||
this.subscriptions.clear();
|
||||
this.objectUpdateSubscriptions.clear();
|
||||
logger.debug(`Removed ${removePromises.length} update listener(s)`);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
17
src/utils.js
Normal file
17
src/utils.js
Normal file
@ -0,0 +1,17 @@
|
||||
export function formatTraceData(value, maxLength = 128) {
|
||||
if (value === undefined) return "undefined";
|
||||
const text = typeof value === "string" ? value : JSON.stringify(value);
|
||||
return text.length > maxLength ? `${text.slice(0, maxLength)}...` : text;
|
||||
}
|
||||
|
||||
export function getQueryToCacheKey({ model, id, populate }) {
|
||||
const populateKey = [];
|
||||
for (const pop of populate) {
|
||||
if (typeof pop === "string") {
|
||||
populateKey.push(pop);
|
||||
} else if (typeof pop === "object") {
|
||||
populateKey.push(pop.path);
|
||||
}
|
||||
}
|
||||
return `${model}:${id?.toString()}-${populateKey.join(",")}`;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user