Initial Commit
Some checks failed
farmcontrol/farmcontrol-scheduler/pipeline/head There was a failure building this commit

This commit is contained in:
Tom Butcher 2026-09-06 23:06:27 +01:00
commit f9284ed1f2
89 changed files with 23501 additions and 0 deletions

13
.eslintignore Normal file
View 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
View 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
View 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
View File

@ -0,0 +1,7 @@
node_modules/
dist/
build/
*.min.js
package-lock.json
.git/
.vscode/

104
Jenkinsfile vendored Normal file
View 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
View File

@ -0,0 +1,83 @@
# Farm Control Scheduler
[![Build Status](https://ci.tombutcher.work/buildStatus/icon?job=farmcontrol%2Ffarmcontrol-scheduler%2Fmain&style=flat-square)](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
View 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
View 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

File diff suppressed because it is too large Load Diff

55
src/config.js Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

59
src/database/mongo.js Normal file
View 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
View 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 };

View 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
View 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 };

View 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',
});
});
});

View 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);

View 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);

View 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);

View 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);

View 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);

View 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);

View 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);

View 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);

View 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);

View 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);

View 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,
});
}
}

View 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);

View 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);

View 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);

View 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);

View 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);

View 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);

View 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);

View 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);

View 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);

View 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);

View 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);

View 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);

View 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);

View 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);

View 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);

View 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);

View 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);

View 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);

View 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);

View 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
);

View 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);

View 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);

View 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);

View 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);

View 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);

View 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);

View 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);

View 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);

View 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);

View 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);

View 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);

View 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);

View 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);

View 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'),
};

View 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);

View 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);

View 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);

View 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);

View 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);

View 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);

View 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);

View 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);

View 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);

View 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);
}

View 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);

View 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 }
);
}

View 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);

View 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);

View 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
View 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
View 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
View 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);
});
})();

View 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",
});
});
});

View 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();
});
});
});

View 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();
});
});

View 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(),
};
}
}

View 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;
}
}
}

View 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;
}
});

View 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);
});
});
});

View 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
View 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(",")}`;
}