Compare commits

..

No commits in common. "main" and "config-updates" have entirely different histories.

371 changed files with 891250 additions and 70838 deletions

View File

@ -1,2 +1,9 @@
SESSION_SECRET='SECRET'
KEYCLOAK_CLIENT_SECRET='SECRET'
DB_LINK="mongo-link-to-connect"
JWT_SECRET="token"
APP_URL_CLIENT=https://material-dashboard-react-node.creative-tim.com
APP_URL_API=https://node-json-api-free.creative-tim.com/login
MAILTRAP_USER=
MAILTRAP_PASSWORD=

5
.gitignore vendored
View File

@ -140,8 +140,3 @@ gocdefile/*
gcodefile
gcodefiles/*
gcodefiles
test-results.xml
# Jenkins generated build metadata
src/buildInfo.json

132
Jenkinsfile vendored
View File

@ -1,132 +0,0 @@
properties([
buildDiscarder(logRotator(numToKeepStr: '25'))
])
pipeline {
agent {
label 'ubuntu'
}
environment {
NODE_ENV = 'production'
}
stages {
stage('Checkout') {
steps {
checkout scm
}
}
stage('Setup Node.js') {
steps {
nodejs(nodeJSInstallationName: 'Node23') {
sh 'node -v'
sh 'pnpm -v'
}
}
}
stage('Write Build Metadata') {
steps {
nodejs(nodeJSInstallationName: 'Node23') {
sh '''
node -e "const fs = require('fs'); fs.writeFileSync('src/buildInfo.json', JSON.stringify({ buildNumber: process.env.BUILD_NUMBER || 'dev' }, null, 2) + '\\n');"
'''
}
}
}
stage('Install Dependencies') {
steps {
nodejs(nodeJSInstallationName: 'Node23') {
sh 'pnpm install --frozen-lockfile --production=false'
}
}
}
stage('Run Tests') {
steps {
nodejs(nodeJSInstallationName: 'Node23') {
sh '''
export NODE_ENV=test
export SESSION_SECRET=test-session-secret-for-testing-only
pnpm test
'''
}
}
post {
always {
junit 'test-results.xml'
}
}
}
stage('Deploy via SSH') {
steps {
sshPublisher(publishers: [
sshPublisherDesc(
configName: 'farmcontrol.tombutcher.local',
transfers: [
sshTransfer(
cleanRemote: false,
excludes: '*.*',
execCommand: '''
if [ -e /home/farmcontrol/farmcontrol-api/src ]; then
rm -rf /home/farmcontrol/farmcontrol-api/src
fi
if [ -e /home/farmcontrol/farmcontrol-api/package.json ]; then
rm -rf /home/farmcontrol/farmcontrol-api/package.json
fi
if [ -e /home/farmcontrol/farmcontrol-api/package-lock.json ]; then
rm -rf /home/farmcontrol/farmcontrol-api/package-lock.json
fi
if [ -e /home/farmcontrol/farmcontrol-api/node_modules ]; then
rm -rf /home/farmcontrol/farmcontrol-api/node_modules
fi
''',
execTimeout: 120000,
flatten: true,
makeEmptyDirs: true,
noDefaultExcludes: false,
patternSeparator: '[, ]+',
remoteDirectory: 'farmcontrol-api',
remoteDirectorySDF: false,
removePrefix: '',
sourceFiles: ''
),
sshTransfer(
cleanRemote: false,
excludes: 'node_modules/**',
execCommand: '''
cd /home/farmcontrol/farmcontrol-api
pnpm install --production
sudo systemctl restart farmcontrol-api
''',
execTimeout: 120000,
flatten: false,
makeEmptyDirs: true,
noDefaultExcludes: false,
patternSeparator: '[, ]+',
remoteDirectory: 'farmcontrol-api',
remoteDirectorySDF: false,
removePrefix: '',
sourceFiles: '**/*'
)
],
usePromotionTimestamp: false,
useWorkspaceInPromotion: false,
verbose: true
)
])
}
}
}
post {
always {
cleanWs()
}
}
}

View File

@ -1,7 +1,5 @@
# FarmControl API
[![Build Status](https://ci.tombutcher.work/buildStatus/icon?job=farmcontrol%2Ffarmcontrol-api%2Fmain&style=flat-square)](https://ci.tombutcher.work/job/farmcontrol/job/farmcontrol-api/job/main/)
A comprehensive REST API for managing 3D printing farms, inventory, and production workflows. Built with Node.js, Express, and MongoDB, featuring authentication with Keycloak and comprehensive logging.
## 🚀 Features
@ -155,7 +153,7 @@ The application uses Keycloak for authentication. Configure your Keycloak server
The application uses MongoDB. Ensure your MongoDB instance is running and accessible at the configured `DB_LINK`.
### CORS Configuration
Allowed request origins are configured via `server.corsOrigins` in `config.json`. When omitted, the API falls back to `app.urlClient` and `app.urlElectronClient`.
The application is configured to allow requests only from the specified `APP_URL_CLIENT`. Update this in your environment configuration.
## 🚀 Deployment

View File

@ -1,4 +0,0 @@
module.exports = {
presets: [['@babel/preset-env', { targets: { node: 'current' } }]],
plugins: ['transform-import-meta'],
};

View File

@ -2,31 +2,22 @@
"development": {
"server": {
"port": 8787,
"logLevel": "debug",
"corsOrigins": [
"https://web.farmcontrol.app",
"https://dev.tombutcher.work",
"http://localhost:5173",
"http://localhost:3000",
"http://localhost:5780"
]
"logLevel": "debug"
},
"auth": {
"enabled": true,
"keycloak": {
"url": "https://auth.tombutcher.work",
"realm": "master",
"clientId": "farmcontrol-dev"
"clientId": "farmcontrol-client"
},
"requiredRoles": []
},
"app": {
"urlClient": "https://dev.tombutcher.work",
"urlElectronClient": "http://localhost:5780",
"urlApi": "https://dev.tombutcher.work/api",
"devAuthClient": "http://localhost:3500",
"jenkinsProject": "https://ci.tombutcher.work/job/farmcontrol/job/farmcontrol-ui",
"appUpdateStorage": "https://dist.farmcontrol.app/jenkins/farmcontrol/farmcontrol-ui"
"urlClient": "http://localhost:3000",
"urlElectronClient": "http://localhost:3000",
"urlApi": "http://localhost:8787",
"devAuthClient": "http://localhost:3500"
},
"database": {
"mongo": {
@ -55,84 +46,12 @@
"filesBucket": "farmcontrol"
}
},
"smtp": {
"host": "mail.tombutcher.work",
"port": 465,
"secure": true,
"auth": {
"user": "farmcontrol",
"pass": "XwV5u3jWufuo5E5U4N9hBHfNfwk28D7fNdFN"
},
"from": "FarmControl <farmcontrol@tombutcher.work>"
},
"otpExpiryMins": 0.5
},
"test": {
"server": {
"port": 8788,
"logLevel": "error"
},
"auth": {
"enabled": false,
"sessionSecret": "test-session-secret-for-testing-only",
"keycloak": {
"url": "http://localhost:8080",
"realm": "test",
"clientId": "test-client"
},
"requiredRoles": []
},
"app": {
"urlClient": "http://localhost:3000",
"urlElectronClient": "http://localhost:5780",
"urlApi": "http://localhost:8788/api",
"devAuthClient": "http://localhost:3500",
"jenkinsProject": "https://ci.tombutcher.work/job/farmcontrol/job/farmcontrol-ui",
"appUpdateStorage": "https://dist.farmcontrol.app/jenkins/farmcontrol/farmcontrol-ui"
},
"database": {
"mongo": {
"url": "mongodb://127.0.0.1:27017/farmcontrol-test",
"link": "127.0.0.1:27017"
},
"redis": {
"host": "localhost",
"port": 6379,
"password": "",
"cacheTtl": 30
},
"nats": {
"host": "localhost",
"port": 4222
}
},
"storage": {
"fileStorage": "./test-uploads",
"ceph": {
"accessKeyId": "minioadmin",
"secretAccessKey": "minioadmin123",
"endpoint": "http://127.0.0.1:9000",
"region": "us-east-1",
"filesBucket": "farmcontrol-test"
}
},
"smtp": {
"host": "localhost",
"port": 587,
"secure": false,
"auth": {
"user": "",
"pass": ""
},
"from": "FarmControl <farmcontrol@tombutcher.work>"
},
"otpExpiryMins": 0.5
},
"production": {
"server": {
"port": 8080,
"logLevel": "info",
"corsOrigins": ["https://web.farmcontrol.app", "views://mainview"]
"logLevel": "info"
},
"auth": {
"enabled": true,
@ -144,17 +63,15 @@
"requiredRoles": []
},
"app": {
"urlClient": "https://web.farmcontrol.app",
"urlClient": "http://localhost:3000",
"urlElectronClient": "http://localhost:3000",
"urlApi": "https://api.farmcontrol.app",
"devAuthClient": "http://localhost:3500",
"jenkinsProject": "https://ci.tombutcher.work/job/farmcontrol/job/farmcontrol-ui",
"appUpdateStorage": "https://dist.farmcontrol.app/jenkins/farmcontrol/farmcontrol-ui"
"urlApi": "http://localhost:8080",
"devAuthClient": "http://localhost:3500"
},
"database": {
"mongo": {
"url": "mongodb://192.168.68.38:27017/farmcontrol",
"link": "192.168.68.38:27017"
"url": "mongodb://localhost:27017/farmcontrol",
"link": "localhost:27017"
},
"redis": {
"url": "",
@ -173,20 +90,10 @@
"ceph": {
"accessKeyId": "minioadmin",
"secretAccessKey": "minioadmin123",
"endpoint": "http://192.168.68.39:9000",
"endpoint": "http://127.0.0.1:9000",
"region": "us-east-1",
"filesBucket": "farmcontrol"
}
},
"smtp": {
"host": "mail.tombutcher.work",
"port": 465,
"secure": true,
"auth": {
"user": "farmcontrol",
"pass": "XwV5u3jWufuo5E5U4N9hBHfNfwk28D7fNdFN"
},
"from": "FarmControl <noreply@farmcontrol.app>"
}
}
}

View File

@ -1,14 +0,0 @@
export default {
apps: [
{
name: 'farmcontrol-api',
script: 'src/index.js',
env: {
NODE_ENV: 'development',
},
env_production: {
NODE_ENV: 'production',
},
},
],
};

View File

@ -1,4 +1,3 @@
import { diffLines } from 'diff';
import fs from 'fs/promises';
import path from 'path';
import { fileURLToPath } from 'url';
@ -12,60 +11,24 @@ async function syncModelsWithWS() {
console.log(`Syncing schemas from ${sourceDir} to ${targetDir}...`);
const stats = { copied: 0, added: 0, skipped: 0 };
try {
await syncDirectory(sourceDir, targetDir, sourceDir, stats);
console.log(
`✅ Schema sync completed successfully! (${stats.added} added, ${stats.copied} updated, ${stats.skipped} unchanged)`
);
await syncDirectory(sourceDir, targetDir, sourceDir);
console.log('✅ Schema sync completed successfully!');
} catch (error) {
console.error('❌ Error syncing schemas:', error);
process.exit(1);
}
}
async function syncModelsWithScheduler() {
const sourceDir = path.resolve(__dirname, 'src/database/schemas');
const targetDir = path.resolve(__dirname, '../farmcontrol-scheduler/src/database/schemas');
console.log(`Syncing schemas from ${sourceDir} to ${targetDir}...`);
const stats = { copied: 0, added: 0, skipped: 0 };
try {
await syncDirectory(sourceDir, targetDir, sourceDir, stats);
console.log(
`✅ Schema sync completed successfully! (${stats.added} added, ${stats.copied} updated, ${stats.skipped} unchanged)`
);
} catch (error) {
console.error('❌ Error syncing schemas:', error);
process.exit(1);
}
}
function fileHasChanges(oldContent, newContent) {
return diffLines(oldContent, newContent).some((part) => part.added || part.removed);
}
async function readFileIfExists(filePath) {
try {
return await fs.readFile(filePath, 'utf8');
} catch (error) {
if (error.code === 'ENOENT') {
return null;
}
throw error;
}
}
async function syncDirectory(source, target, rootSource, stats) {
async function syncDirectory(source, target, rootSource) {
// Create target directory if it doesn't exist
try {
await fs.access(target);
} catch {
await fs.mkdir(target, { recursive: true });
}
// Read all items in source directory
const items = await fs.readdir(source, { withFileTypes: true });
for (const item of items) {
@ -73,29 +36,17 @@ async function syncDirectory(source, target, rootSource, stats) {
const targetPath = path.join(target, item.name);
if (item.isDirectory()) {
await syncDirectory(sourcePath, targetPath, rootSource, stats);
// Recursively sync subdirectories
await syncDirectory(sourcePath, targetPath, rootSource);
} else if (item.isFile()) {
const sourceContent = await fs.readFile(sourcePath, 'utf8');
const targetContent = await readFileIfExists(targetPath);
const relativePath = path.relative(rootSource, sourcePath);
if (targetContent === null) {
await fs.writeFile(targetPath, sourceContent);
stats.added += 1;
console.log(` + Added: ${relativePath}`);
} else if (fileHasChanges(targetContent, sourceContent)) {
await fs.writeFile(targetPath, sourceContent);
stats.copied += 1;
console.log(` ✓ Updated: ${relativePath}`);
} else {
stats.skipped += 1;
}
// Copy file from source to target
await fs.copyFile(sourcePath, targetPath);
console.log(` ✓ Copied: ${path.relative(rootSource, sourcePath)}`);
}
}
}
// Run the sync function when executed directly
syncModelsWithWS();
syncModelsWithScheduler();
export { syncModelsWithWS, syncModelsWithScheduler };
export { syncModelsWithWS };

BIN
images/admin.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

BIN
images/creator.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

BIN
images/member.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

View File

@ -1,22 +0,0 @@
module.exports = {
testEnvironment: 'node',
transform: {},
moduleFileExtensions: ['js', 'json', 'jsx', 'node'],
testMatch: ['**/__tests__/**/*.js', '**/?(*.)+(spec|test).js'],
verbose: true,
reporters: [
'default',
[
'jest-junit',
{
outputDirectory: '.',
outputName: 'test-results.xml',
suiteName: 'farmcontrol-api-tests',
classNameTemplate: '{classname}',
titleTemplate: '{title}',
ancestorSeparator: ' › ',
usePathForSuiteName: 'true',
},
],
],
};

12211
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -9,41 +9,28 @@
"@nats-io/transport-node": "^3.2.0",
"axios": "^1.13.2",
"bcrypt": "^6.0.0",
"blurhash": "^2.0.5",
"body-parser": "^2.2.0",
"canonical-json": "^0.2.0",
"cors": "^2.8.5",
"dayjs": "^1.11.23",
"diff": "^9.0.0",
"dotenv": "^17.2.3",
"ejs": "^3.1.10",
"exceljs": "^4.4.0",
"exifr": "^7.1.3",
"express": "^5.1.0",
"express-session": "^1.18.2",
"i": "^0.3.7",
"ioredis": "^6.0.0",
"js-beautify": "^2.0.3",
"jsonwebtoken": "^9.0.2",
"keycloak-connect": "^26.1.1",
"lodash": "^4.17.23",
"log4js": "^6.9.1",
"mongodb": "^6.21.0",
"mongoose": "^9.9.1",
"mongoose": "^8.19.4",
"multer": "^2.0.2",
"nanoid": "^5.1.6",
"node-cache": "^5.1.2",
"node-cron": "^4.2.1",
"nodemailer": "*",
"nodemon": "^3.1.11",
"pdf-lib": "^1.17.1",
"pdf-to-img": "^6.2.0",
"pg": "^8.16.3",
"posthtml": "^0.16.7",
"puppeteer": "^24.37.5",
"sequelize": "^6.37.7",
"sharp": "^0.34.3",
"speedgoose": "^2.5.11"
"redis": "^5.10.0",
"sequelize": "^6.37.7"
},
"type": "module",
"devDependencies": {
@ -54,31 +41,23 @@
"@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"
"standard": "^17.1.2"
},
"scripts": {
"syncModelsWithWS": "node fcdev.js",
"watch:schemas": "nodemon --config nodemon.schemas.json",
"dev": "concurrently --kill-others --names \"API,SCHEMAS\" --prefix-colors \"cyan,yellow\" \"nodemon --exec babel-node --experimental-specifier-resolution=node src/index.js\" \"nodemon --config nodemon.schemas.json\"",
"dev": "concurrently --names \"API,SCHEMAS\" --prefix-colors \"cyan,yellow\" \"nodemon --exec babel-node --experimental-specifier-resolution=node src/index.js\" \"nodemon --config nodemon.schemas.json\"",
"dev:api": "nodemon --exec babel-node --experimental-specifier-resolution=node src/index.js",
"test": "node --experimental-vm-modules ./node_modules/jest/bin/jest.js",
"test": "echo \"Error: no test specified\" && exit 1",
"seed": "node src/mongo/seedData.js",
"clear": "node src/mongo/clearDbs.js",
"start": "node src/index.js"
"clear": "node src/mongo/clearDbs.js"
},
"author": "",
"license": "ISC",
"packageManager": "pnpm@10.28.0"
"license": "ISC"
}

11813
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1,9 +0,0 @@
minimumReleaseAge: 0
allowBuilds:
bcrypt: true
chromedriver: true
core-js: true
puppeteer: true
sharp: true
unrs-resolver: true

View File

@ -1,16 +0,0 @@
import { actorDisplayName, resolveAuditOwner } from '../auditOwner.js';
describe('audit owner resolution', () => {
it('represents system actions without an owner', () => {
expect(resolveAuditOwner('system')).toEqual({ system: true });
expect(actorDisplayName('system')).toBe('system');
});
it('preserves object actors as owners', () => {
expect(resolveAuditOwner({ _id: 'user-1' })).toEqual({
system: false,
owner: 'user-1',
ownerType: 'user',
});
});
});

View File

@ -1,138 +0,0 @@
import { jest } from '@jest/globals';
const userQueryMock = {
lean: jest.fn(),
};
const userModelMock = {
findOne: jest.fn(() => userQueryMock),
};
const appPasswordQueryMock = {
select: jest.fn(),
lean: jest.fn(),
};
const appPasswordModelMock = {
find: jest.fn(() => appPasswordQueryMock),
};
const bcryptCompareMock = jest.fn();
jest.unstable_mockModule('../config.js', () => ({
default: { server: { logLevel: 'info' } },
getEnvironment: jest.fn(),
}));
jest.unstable_mockModule('log4js', () => ({
default: {
getLogger: () => ({
level: 'info',
debug: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
info: jest.fn(),
}),
},
}));
jest.unstable_mockModule('node-cache', () => ({
default: class {
get() {}
set() {}
on() {}
flushAll() {}
getStats() {}
del() {}
},
}));
jest.unstable_mockModule('bcrypt', () => ({
default: { compare: bcryptCompareMock },
}));
jest.unstable_mockModule('../database/schemas/management/user.schema.js', () => ({
userModel: userModelMock,
}));
jest.unstable_mockModule('../database/schemas/management/apppassword.schema.js', () => ({
appPasswordModel: appPasswordModelMock,
}));
jest.unstable_mockModule('../database/database.js', () => ({
getPropertyValues: jest.fn(),
getObject: jest.fn(),
getObjectNeighbors: jest.fn(),
}));
jest.unstable_mockModule('../database/schemas/management/host.schema.js', () => ({
hostModel: {},
}));
jest.unstable_mockModule('../services/misc/auth.js', () => ({
getSession: jest.fn(),
lookupUserByToken: jest.fn(),
}));
const { isAppAuthenticated } = await import('../keycloak.js');
describe('app-password authentication', () => {
let req;
let res;
let next;
beforeEach(() => {
jest.clearAllMocks();
appPasswordQueryMock.select.mockReturnValue(appPasswordQueryMock);
req = {
headers: {
authorization: `Basic ${Buffer.from('slicer-user:app-secret').toString('base64')}`,
},
params: { username: 'slicer-user' },
};
res = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
};
next = jest.fn();
});
it('accepts a valid username and app password', async () => {
const user = { _id: 'user-1', username: 'slicer-user' };
userQueryMock.lean.mockResolvedValue(user);
appPasswordQueryMock.lean.mockResolvedValue([{ secret: 'stored-hash' }]);
bcryptCompareMock.mockResolvedValue(true);
await isAppAuthenticated(req, res, next);
expect(req.user).toBe(user);
expect(next).toHaveBeenCalledTimes(1);
expect(res.status).not.toHaveBeenCalled();
});
it('accepts the app password from the password route parameter', async () => {
const user = { _id: 'user-1', username: 'slicer-user' };
req.headers = {};
req.params.password = 'app-secret';
userQueryMock.lean.mockResolvedValue(user);
appPasswordQueryMock.lean.mockResolvedValue([{ secret: 'stored-hash' }]);
bcryptCompareMock.mockResolvedValue(true);
await isAppAuthenticated(req, res, next);
expect(bcryptCompareMock).toHaveBeenCalledWith('app-secret', 'stored-hash');
expect(req.user).toBe(user);
expect(next).toHaveBeenCalledTimes(1);
});
it('rejects an invalid app password', async () => {
userQueryMock.lean.mockResolvedValue({ _id: 'user-1', username: 'slicer-user' });
appPasswordQueryMock.lean.mockResolvedValue([{ secret: 'stored-hash' }]);
bcryptCompareMock.mockResolvedValue(false);
await isAppAuthenticated(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(401);
expect(res.json).toHaveBeenCalledWith({
error: 'Not Authenticated',
code: 'UNAUTHORIZED',
});
});
});

View File

@ -1,86 +0,0 @@
import { jest } from '@jest/globals';
import request from 'supertest';
// Mock Keycloak and Auth
jest.unstable_mockModule('../keycloak.js', () => ({
keycloak: {
middleware: () => (req, res, next) => next(),
protect: () => (req, res, next) => next(),
},
isAuthenticated: (req, res, next) => next(),
isAppAuthenticated: (req, res, next) => next(),
isAppQueryAuthenticated: (req, res, next) => next(),
expressSession: (req, res, next) => next(),
}));
jest.unstable_mockModule('../database/permissions.js', () => ({
checkPermissions: () => (req, res, next) => next(),
hasPermission: jest.fn(async () => true),
getUserPermissionsCacheKey: jest.fn((userId) => `permissions:${userId}`),
saveUserPermissionsToRedis: jest.fn(),
applyPermissionSettingsList: jest.fn(() => ({})),
getPermissionSettingsId: jest.fn(),
excludeIdFromList: jest.fn((items = []) => items),
resolveReferencedDocs: jest.fn(async () => []),
resolvePermissionSettings: jest.fn(async () => []),
}));
// Mock database connections and initializations in index.js
jest.unstable_mockModule('../database/mongo.js', () => ({
dbConnect: jest.fn(),
}));
jest.unstable_mockModule('../database/nats.js', () => ({
natsServer: { connect: jest.fn() },
}));
jest.unstable_mockModule('../database/ceph.js', () => ({
initializeBuckets: jest.fn(),
uploadFile: jest.fn(),
downloadFile: jest.fn(),
deleteFile: jest.fn(),
fileExists: jest.fn(),
listFiles: jest.fn(),
getFileMetadata: jest.fn(),
getPresignedUrl: jest.fn(),
BUCKETS: { FILES: 'test-bucket' },
}));
// Mock the service handlers to avoid database calls
jest.unstable_mockModule('../services/management/users.js', () => ({
listUsersRouteHandler: jest.fn((req, res) => res.send([{ id: '1', name: 'Mock User' }])),
listUsersByPropertiesRouteHandler: jest.fn(),
searchUsersRouteHandler: jest.fn(),
getUserRouteHandler: jest.fn((req, res) => res.send({ id: req.params.id, name: 'Mock User' })),
editUserRouteHandler: jest.fn(),
getUserStatsRouteHandler: jest.fn(),
getUserHistoryRouteHandler: jest.fn(),
setAppPasswordRouteHandler: jest.fn(),
getUserPropertyValuesRouteHandler: jest.fn(),
getUserNeighborsRouteHandler: jest.fn(),
}));
const { default: app } = await import('../index.js');
const { listUsersRouteHandler, getUserRouteHandler } = await import(
'../services/management/users.js'
);
describe('Users API Endpoints', () => {
describe('GET /users', () => {
it('should return a list of users', async () => {
const response = await request(app).get('/users');
expect(response.status).toBe(200);
expect(response.body).toEqual([{ id: '1', name: 'Mock User' }]);
expect(listUsersRouteHandler).toHaveBeenCalled();
});
});
describe('GET /users/:id', () => {
it('should return a single user', async () => {
const response = await request(app).get('/users/123');
expect(response.status).toBe(200);
expect(response.body).toEqual({ id: '123', name: 'Mock User' });
expect(getUserRouteHandler).toHaveBeenCalled();
});
});
});

View File

@ -1,45 +0,0 @@
import { describe, expect, it } from '@jest/globals';
import { mongoose } from 'mongoose';
import { ObjectId as BsonObjectId } from 'mongodb';
import { expandObjectIds } from '../utils.js';
const HEX = '692f07c4cb1de2995c4d543a';
const BYTES = [105, 47, 7, 196, 203, 29, 226, 153, 92, 77, 84, 58];
describe('expandObjectIds', () => {
it('stores mongoose ObjectIds as hex text', () => {
const id = new mongoose.Types.ObjectId(HEX);
const result = expandObjectIds({ location: id, _id: id });
expect(result.location).toEqual({ _id: HEX });
expect(result._id).toBe(HEX);
expect(result.location._id).not.toHaveProperty('buffer');
});
it('stores BSON ObjectIds as hex text even when they are not mongoose instances', () => {
const id = new BsonObjectId(HEX);
expect(id instanceof mongoose.Types.ObjectId).toBe(false);
const result = expandObjectIds({ product: id });
expect(result.product).toEqual({ _id: HEX });
expect(typeof result.product._id).toBe('string');
});
it('converts serialized 12-byte buffer objects to hex text', () => {
const serialized = {
buffer: Object.fromEntries(BYTES.map((byte, index) => [String(index), byte])),
};
const result = expandObjectIds({ sku: serialized });
expect(result.sku).toEqual({ _id: HEX });
});
it('converts 12-byte Buffers to hex text instead of expanding index keys', () => {
const result = expandObjectIds({ sku: Buffer.from(HEX, 'hex') });
expect(result.sku).toEqual({ _id: HEX });
expect(result.sku).not.toHaveProperty('0');
});
});

View File

@ -1,42 +0,0 @@
import { describe, expect, it } from '@jest/globals';
import mongoose from 'mongoose';
import {
collectIdsAtPath,
getFieldsByRef,
modelHasRef,
} from '../utils.js';
import { emailMessageModel } from '../database/schemas/management/emailmessage.schema.js';
describe('getFieldsByRef', () => {
it('finds nested array file refs on email message recipients', () => {
expect(modelHasRef(emailMessageModel, 'file')).toBe(true);
expect(getFieldsByRef(emailMessageModel, 'file')).toEqual(['recipients.attachments']);
});
it('finds top-level array refs using embeddedSchemaType', () => {
const schema = new mongoose.Schema({
files: [{ type: mongoose.Schema.Types.ObjectId, ref: 'file' }],
});
const model = { schema };
expect(modelHasRef(model, 'file')).toBe(true);
expect(getFieldsByRef(model, 'file')).toEqual(['files']);
});
});
describe('collectIdsAtPath', () => {
it('collects file ids from nested recipient attachment lists', () => {
const object = {
recipients: [
{ attachments: [{ _id: 'file-1' }, 'file-2'] },
{ attachments: ['file-3'] },
],
};
expect(collectIdsAtPath(object, 'recipients.attachments')).toEqual([
'file-1',
'file-2',
'file-3',
]);
});
});

View File

@ -1,268 +0,0 @@
import { describe, expect, it } from '@jest/globals';
import { getFilter, parseFilter } from '../utils.js';
import { jobModel } from '../database/schemas/production/job.schema.js';
const startOfDay = (date) => {
const d = new Date(date);
d.setHours(0, 0, 0, 0);
return d;
};
const endOfDay = (date) => {
const d = new Date(date);
d.setHours(23, 59, 59, 999);
return d;
};
const startOfMonth = (date) => new Date(date.getFullYear(), date.getMonth(), 1, 0, 0, 0, 0);
const endOfMonth = (date) =>
new Date(date.getFullYear(), date.getMonth() + 1, 0, 23, 59, 59, 999);
const startOfWeek = (date) => {
const d = startOfDay(date);
const day = d.getDay();
const mondayOffset = day === 0 ? -6 : 1 - day;
d.setDate(d.getDate() + mondayOffset);
return d;
};
const endOfWeek = (date) => {
const start = startOfWeek(date);
const end = new Date(start);
end.setDate(end.getDate() + 6);
return endOfDay(end);
};
const startOfYear = (date) => new Date(date.getFullYear(), 0, 1, 0, 0, 0, 0);
const endOfYear = (date) => new Date(date.getFullYear(), 11, 31, 23, 59, 59, 999);
const startOfHour = (date) => {
const d = new Date(date);
d.setMinutes(0, 0, 0);
return d;
};
const endOfHour = (date) => {
const d = new Date(date);
d.setMinutes(59, 59, 999);
return d;
};
const startOfMinute = (date) => {
const d = new Date(date);
d.setSeconds(0, 0);
return d;
};
const endOfMinute = (date) => {
const d = new Date(date);
d.setSeconds(59, 999);
return d;
};
describe('parseFilter number fields', () => {
it('matches exact numeric quantity without regex', async () => {
await expect(parseFilter('quantity', '12', jobModel)).resolves.toEqual({ quantity: 12 });
});
it('matches multiple quantities with $in', async () => {
await expect(parseFilter('quantity', '1|2|3', jobModel)).resolves.toEqual({
quantity: { $in: [1, 2, 3] },
});
});
it('does not put $regex or $options on quantity for wildcards', async () => {
const result = await parseFilter('quantity', '1*', jobModel);
expect(result.quantity).toBeUndefined();
expect(JSON.stringify(result)).not.toMatch(/\$options/);
expect(result).toEqual({
$expr: {
$regexMatch: {
input: { $toString: '$quantity' },
regex: '^1.*$',
options: 'i',
},
},
});
});
it('treats non-numeric quantity text as no match', async () => {
await expect(parseFilter('quantity', 'abc', jobModel)).resolves.toEqual({
quantity: { $in: [] },
});
});
it('still uses $regex on string fields', async () => {
const result = await parseFilter('_reference', 'JOB*', jobModel);
expect(result._reference).toEqual({ $regex: '^JOB.*$', $options: 'i' });
});
it('strips model prefix from equality expressions', async () => {
await expect(parseFilter('_reference', 'GCF:EJX3EXUZYW2U', jobModel)).resolves.toEqual({
_reference: { $regex: '^EJX3EXUZYW2U$', $options: 'i' },
});
});
it('strips model prefix from not-equal expressions', async () => {
await expect(parseFilter('_reference', '<>GCF:EJX3EXUZYW2U', jobModel)).resolves.toEqual({
_reference: { $ne: 'EJX3EXUZYW2U' },
});
});
});
describe('parseFilter empty values', () => {
it("treats '' as null or missing", async () => {
await expect(parseFilter('_reference', "''", jobModel)).resolves.toEqual({
_reference: null,
});
});
it("treats ='' as equal to null or missing", async () => {
await expect(parseFilter('_reference', "=''", jobModel)).resolves.toEqual({
_reference: null,
});
});
it("treats <>'' as present and not null", async () => {
await expect(parseFilter('_reference', "<>''", jobModel)).resolves.toEqual({
_reference: { $ne: null },
});
});
it("treats '' as empty on number fields", async () => {
await expect(parseFilter('quantity', "''", jobModel)).resolves.toEqual({
quantity: null,
});
});
it("treats <>'' as not empty on date fields", async () => {
await expect(parseFilter('createdAt', "<>''", jobModel)).resolves.toEqual({
createdAt: { $ne: null },
});
});
it("treats '' as empty on object references", async () => {
await expect(parseFilter('gcodeFile', "''", jobModel)).resolves.toEqual({
gcodeFile: null,
});
});
it("treats <>'' as not empty on object references", async () => {
await expect(parseFilter('gcodeFile', "<>''", jobModel)).resolves.toEqual({
gcodeFile: { $ne: null },
});
});
});
describe('parseFilter date fields', () => {
it('matches TODAY as the current calendar day', async () => {
const now = new Date();
await expect(parseFilter('createdAt', 'TODAY', jobModel)).resolves.toEqual({
createdAt: { $gte: startOfDay(now), $lte: endOfDay(now) },
});
});
it('matches TODAY.. as from today onwards', async () => {
const now = new Date();
await expect(parseFilter('createdAt', 'TODAY..', jobModel)).resolves.toEqual({
createdAt: { $gte: startOfDay(now) },
});
});
it('matches -7D.. as the last 7 days', async () => {
const from = new Date();
from.setDate(from.getDate() - 7);
await expect(parseFilter('createdAt', '-7D..', jobModel)).resolves.toEqual({
createdAt: { $gte: startOfDay(from) },
});
});
it('matches -30D.. as the last 30 days', async () => {
const from = new Date();
from.setDate(from.getDate() - 30);
await expect(parseFilter('createdAt', '-30D..', jobModel)).resolves.toEqual({
createdAt: { $gte: startOfDay(from) },
});
});
it('matches -1M..-CM as the previous calendar month', async () => {
const now = new Date();
const previous = new Date(now.getFullYear(), now.getMonth() - 1, 1);
await expect(parseFilter('createdAt', '-1M..-CM', jobModel)).resolves.toEqual({
createdAt: { $gte: startOfMonth(previous), $lte: endOfMonth(previous) },
});
});
it('matches -1W as the previous calendar week', async () => {
const previous = new Date();
previous.setDate(previous.getDate() - 7);
await expect(parseFilter('createdAt', '-1W', jobModel)).resolves.toEqual({
createdAt: { $gte: startOfWeek(previous), $lte: endOfWeek(previous) },
});
});
it('matches -1Y..-CY as the previous calendar year', async () => {
const previous = new Date(new Date().getFullYear() - 1, 0, 1);
await expect(parseFilter('createdAt', '-1Y..-CY', jobModel)).resolves.toEqual({
createdAt: { $gte: startOfYear(previous), $lte: endOfYear(previous) },
});
});
it('matches -2H as that hour two hours ago', async () => {
const hour = new Date();
hour.setHours(hour.getHours() - 2);
await expect(parseFilter('createdAt', '-2H', jobModel)).resolves.toEqual({
createdAt: { $gte: startOfHour(hour), $lte: endOfHour(hour) },
});
});
it('matches -15m.. as the last 15 minutes', async () => {
const from = new Date();
from.setMinutes(from.getMinutes() - 15);
await expect(parseFilter('createdAt', '-15m..', jobModel)).resolves.toEqual({
createdAt: { $gte: startOfMinute(from) },
});
});
it('does not treat lowercase m as a month', async () => {
const month = await parseFilter('createdAt', '-1M', jobModel);
const minute = await parseFilter('createdAt', '-1m', jobModel);
expect(month).not.toEqual(minute);
const from = new Date();
from.setMinutes(from.getMinutes() - 1);
expect(minute).toEqual({
createdAt: { $gte: startOfMinute(from), $lte: endOfMinute(from) },
});
});
it('omits invalid date text instead of sending it to Mongo', async () => {
await expect(parseFilter('createdAt', 'not-a-date', jobModel)).resolves.toEqual({});
});
it('omits expressions that mix dates with non-date operands', async () => {
await expect(parseFilter('createdAt', 'TODAY|foo', jobModel)).resolves.toEqual({});
});
it('rejects impossible calendar days', async () => {
await expect(parseFilter('createdAt', '32', jobModel)).resolves.toEqual({});
});
it('getFilter drops invalid date fields and keeps valid ones', async () => {
const now = new Date();
const filter = await getFilter(
{ createdAt: 'yesterday', quantity: '12' },
['createdAt', 'quantity'],
true,
jobModel
);
expect(filter).toEqual({ quantity: 12 });
expect(filter.createdAt).toBeUndefined();
const today = await getFilter({ createdAt: 'TODAY' }, ['createdAt'], true, jobModel);
expect(today).toEqual({
createdAt: { $gte: startOfDay(now), $lte: endOfDay(now) },
});
});
});

View File

@ -1,28 +0,0 @@
const AUDIT_OWNER_TYPES = ['user', 'printer', 'host', 'marketplace'];
export function resolveAuditOwner(actor) {
if (actor === 'system') {
return {
system: true,
};
}
const ownerType = AUDIT_OWNER_TYPES.includes(actor?._objectType) ? actor._objectType : 'user';
return {
system: false,
owner: actor?._id,
ownerType,
};
}
export function actorDisplayName(actor) {
if (actor === 'system') return 'system';
if (actor?._objectType === 'marketplace' || actor?._objectType === 'host') {
return actor.name || actor._objectType;
}
const firstName = actor?.firstName ?? 'unknown';
const lastName = actor?.lastName ?? '';
return `${firstName} ${lastName}`.trim();
}
export { AUDIT_OWNER_TYPES };

View File

@ -2,10 +2,6 @@
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);
@ -29,47 +25,7 @@ function loadConfig() {
throw new Error(`Configuration for environment '${NODE_ENV}' not found in config.json`);
}
const envConfig = config[NODE_ENV];
// Ensure auth config exists
if (!envConfig.auth) {
envConfig.auth = {};
}
if (!envConfig.auth.keycloak) {
envConfig.auth.keycloak = {};
}
// Override secrets with environment variables if available
if (process.env.KEYCLOAK_CLIENT_SECRET) {
envConfig.auth.keycloak.clientSecret = process.env.KEYCLOAK_CLIENT_SECRET;
}
// Session secret must be set - use env var or throw error
if (process.env.SESSION_SECRET) {
envConfig.auth.sessionSecret = process.env.SESSION_SECRET;
} else if (!envConfig.auth.sessionSecret) {
throw new Error(
'SESSION_SECRET environment variable is required. Please set SESSION_SECRET in your environment.'
);
}
// Ensure smtp config exists and override with env vars if available
if (!envConfig.smtp) {
envConfig.smtp = {};
}
if (process.env.SMTP_HOST) envConfig.smtp.host = process.env.SMTP_HOST;
if (process.env.SMTP_PORT) envConfig.smtp.port = parseInt(process.env.SMTP_PORT, 10);
if (process.env.SMTP_SECURE) envConfig.smtp.secure = process.env.SMTP_SECURE === 'true';
if (process.env.SMTP_USER || process.env.SMTP_PASS) {
envConfig.smtp.auth = {
...(envConfig.smtp.auth || {}),
...(process.env.SMTP_USER && { user: process.env.SMTP_USER }),
...(process.env.SMTP_PASS && { pass: process.env.SMTP_PASS }),
};
}
if (process.env.SMTP_FROM) envConfig.smtp.from = process.env.SMTP_FROM;
return envConfig;
return config[NODE_ENV];
} catch (err) {
console.error('Error loading config:', err);
throw err;

View File

@ -1,218 +0,0 @@
import { jest } from '@jest/globals';
// Mock src/database/utils.js (where generateId and convertObjectIdStringsInFilter live)
jest.unstable_mockModule('../utils.js', () => ({
generateId: jest.fn(() => () => 'test-id'),
convertObjectIdStringsInFilter: jest.fn((filter) => filter),
}));
// Mock src/utils.js (where most database.js helpers live)
jest.unstable_mockModule('../../utils.js', () => ({
deleteAuditLog: jest.fn(),
deleteNotification: jest.fn(),
distributeDelete: jest.fn(),
editAuditLog: jest.fn(),
editNotification: jest.fn(),
expandObjectIds: jest.fn((obj) => obj),
flatternObjectIds: jest.fn((object) => {
if (!object || typeof object !== 'object') {
return object;
}
const result = {};
for (const [key, value] of Object.entries(object)) {
if (value && typeof value === 'object' && value._id) {
result[key] = value._id;
} else {
result[key] = value;
}
}
return result;
}),
getFieldsByRef: jest.fn(() => []),
collectIdsAtPath: jest.fn(() => []),
getQueryToCacheKey: jest.fn(({ model, id }) => `${model}:${id}`),
modelHasRef: jest.fn(() => false),
newAuditLog: jest.fn(),
distributeNew: jest.fn(),
distributeUpdate: jest.fn(),
distributeChildUpdate: jest.fn(),
distributeChildDelete: jest.fn(),
distributeChildNew: jest.fn(),
distributeStats: jest.fn(),
}));
jest.unstable_mockModule('../redis.js', () => ({
redisServer: {
getKey: jest.fn(),
setKey: jest.fn(),
deleteKey: jest.fn(),
getKeysByPattern: jest.fn(() => []), // Return empty array to avoid iterable error
},
}));
jest.unstable_mockModule('../../services/misc/model.js', () => ({
getAllModels: jest.fn(() => []),
getModelByName: jest.fn(() => null),
}));
jest.unstable_mockModule('../schemas/management/auditlog.schema.js', () => ({
auditLogModel: {
find: jest.fn(),
create: jest.fn(),
},
}));
// Mock fileModel specifically as it's imported by database.js
jest.unstable_mockModule('../schemas/management/file.schema.js', () => ({
fileModel: {
findById: jest.fn(),
},
}));
// Now import the database utilities
const { listObjects, getObject, newObject, editObject, deleteObject } =
await import('../database.js');
const { editAuditLog, editNotification } = await import('../../utils.js');
describe('Database Utilities (CRUD)', () => {
let mockModel;
beforeEach(() => {
mockModel = {
modelName: 'TestModel',
find: jest.fn().mockReturnThis(),
findById: jest.fn().mockReturnThis(),
findByIdAndUpdate: jest.fn().mockReturnThis(),
findByIdAndDelete: jest.fn().mockReturnThis(),
create: jest.fn(),
sort: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
populate: jest.fn().mockReturnThis(),
select: jest.fn().mockReturnThis(),
lean: jest.fn().mockReturnThis(),
exec: jest.fn(),
};
jest.clearAllMocks();
});
describe('listObjects', () => {
it('should return a list of objects', async () => {
const mockData = [{ _id: '1', name: 'Test' }];
mockModel.lean.mockResolvedValue(mockData);
const result = await listObjects({ model: mockModel });
expect(mockModel.find).toHaveBeenCalled();
expect(result).toEqual(mockData);
});
it('should handle pagination', async () => {
await listObjects({ model: mockModel, page: 2, limit: 10 });
expect(mockModel.skip).toHaveBeenCalledWith(10);
expect(mockModel.limit).toHaveBeenCalledWith(10);
});
});
describe('getObject', () => {
it('should return a single object by ID', async () => {
const mockData = { _id: '123', name: 'Test' };
mockModel.lean.mockResolvedValue(mockData);
const result = await getObject({ model: mockModel, id: '123' });
expect(mockModel.findById).toHaveBeenCalledWith('123');
expect(result).toEqual(mockData);
});
it('should return 404 if object not found', async () => {
mockModel.lean.mockResolvedValue(null);
const result = await getObject({ model: mockModel, id: '123' });
expect(result).toEqual({ error: 'Object not found.', code: 404 });
});
});
describe('newObject', () => {
it('should create a new object', async () => {
const newData = { name: 'New' };
const createdData = { _id: '456', ...newData };
mockModel.create.mockResolvedValue({
toObject: () => createdData,
_id: '456',
});
const result = await newObject({ model: mockModel, newData });
expect(mockModel.create).toHaveBeenCalledWith(newData);
expect(result).toEqual(createdData);
});
});
describe('editObject', () => {
it('should update an existing object', async () => {
const id = '123';
const updateData = { name: 'Updated' };
const previousData = { _id: id, name: 'Old' };
mockModel.lean.mockResolvedValue(previousData);
const result = await editObject({ model: mockModel, id, updateData });
expect(mockModel.findByIdAndUpdate).toHaveBeenCalledWith(id, updateData);
expect(result).toEqual({ ...previousData, ...updateData });
});
it('should flatten object id references before updating', async () => {
const id = '123';
const fileId = '507f1f77bcf86cd799439012';
const updateData = { file: { _id: fileId, name: 'part.gcode' } };
const previousData = { _id: id, name: 'Old' };
mockModel.lean.mockResolvedValue(previousData);
await editObject({ model: mockModel, id, updateData });
expect(mockModel.findByIdAndUpdate).toHaveBeenCalledWith(id, { file: fileId });
});
it('creates an audit log but no user notification for system edits', async () => {
mockModel.lean.mockResolvedValue({ _id: '123', name: 'Old' });
await editObject({
model: mockModel,
id: '123',
updateData: { name: 'Updated' },
user: 'system',
});
expect(editAuditLog).toHaveBeenCalledWith(
expect.any(Object),
expect.any(Object),
'123',
'TestModel',
'system'
);
expect(editNotification).not.toHaveBeenCalled();
});
});
describe('deleteObject', () => {
it('should delete an object', async () => {
const id = '123';
const mockData = { _id: id, name: 'To be deleted' };
mockModel.findByIdAndDelete.mockResolvedValue({
toObject: () => mockData,
});
const result = await deleteObject({ model: mockModel, id });
expect(mockModel.findByIdAndDelete).toHaveBeenCalledWith(id);
expect(result).toEqual({ deleted: true, object: mockData });
});
});
});

View File

@ -1,176 +0,0 @@
import { jest } from '@jest/globals';
const redisMock = {
getKey: jest.fn(),
setKey: jest.fn(),
};
const lean = jest.fn();
const select = jest.fn(() => ({ lean }));
const findById = jest.fn(() => ({ select }));
const userModel = { findById };
jest.unstable_mockModule('../redis.js', () => ({
redisServer: redisMock,
}));
jest.unstable_mockModule('mongoose', () => ({
default: {
model: jest.fn(() => userModel),
},
}));
const {
applyPermissionSettingsList,
checkPermissions,
hasPermission,
getUserPermissionsCacheKey,
saveUserPermissionsToRedis,
} = await import('../permissions.js');
describe('applyPermissionSettingsList', () => {
it('applies permission settings in order and inherits intermediate values', () => {
const result = applyPermissionSettingsList([
{
permissions: {
user: { info: true, edit: false, delete: true },
printer: { info: true },
},
},
{
permissions: {
user: { edit: true, delete: null },
printer: { info: null, delete: false },
},
},
]);
expect(result).toEqual({
user: { info: true, edit: true, delete: true },
printer: { info: true, delete: false },
});
});
it('returns an empty object when nothing is explicitly set', () => {
expect(applyPermissionSettingsList([{ permissions: { user: { info: null } } }])).toEqual({});
});
it('applies group permissions first, then user permission settings', () => {
const result = applyPermissionSettingsList([
{
permissions: {
user: { info: true, edit: false },
printer: { info: true },
},
},
{
permissions: {
user: { delete: true },
},
},
{
permissions: {
user: { edit: true },
printer: { info: false },
},
},
]);
expect(result).toEqual({
user: { info: true, edit: true, delete: true },
printer: { info: false },
});
});
});
describe('saveUserPermissionsToRedis', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('saves permissions under permissions:userID', async () => {
const permissions = { user: { info: true } };
await saveUserPermissionsToRedis('user-1', permissions);
expect(redisMock.setKey).toHaveBeenCalledWith('permissions:user-1', permissions);
});
});
describe('hasPermission', () => {
beforeEach(() => {
jest.clearAllMocks();
redisMock.getKey.mockResolvedValue(null);
redisMock.setKey.mockResolvedValue(undefined);
lean.mockResolvedValue(null);
});
it('returns true when the cached permission is allowed', async () => {
redisMock.getKey.mockResolvedValue({ printer: { edit: true } });
await expect(hasPermission({ _id: 'user-1' }, 'printer', 'edit')).resolves.toBe(true);
expect(redisMock.getKey).toHaveBeenCalledWith(getUserPermissionsCacheKey('user-1'));
expect(findById).not.toHaveBeenCalled();
});
it('returns false when the cached permission is denied or missing', async () => {
redisMock.getKey.mockResolvedValue({ printer: { edit: false } });
await expect(hasPermission('user-1', 'printer', 'edit')).resolves.toBe(false);
await expect(hasPermission('user-1', 'printer', 'delete')).resolves.toBe(false);
});
it('loads permissions from mongodb when redis is empty and caches them', async () => {
lean.mockResolvedValue({
permissions: { job: { info: true, edit: false } },
});
await expect(hasPermission('user-2', 'job', 'info')).resolves.toBe(true);
expect(findById).toHaveBeenCalledWith('user-2');
expect(select).toHaveBeenCalledWith('permissions');
expect(redisMock.setKey).toHaveBeenCalledWith('permissions:user-2', {
job: { info: true, edit: false },
});
});
it('returns false when the user or action cannot be resolved', async () => {
await expect(hasPermission(null, 'job', 'info')).resolves.toBe(false);
await expect(hasPermission('user-1', '', 'info')).resolves.toBe(false);
expect(redisMock.getKey).not.toHaveBeenCalled();
});
});
describe('checkPermissions middleware', () => {
const json = jest.fn();
const status = jest.fn(() => ({ json }));
const next = jest.fn();
const res = { status };
beforeEach(() => {
jest.clearAllMocks();
redisMock.getKey.mockResolvedValue(null);
redisMock.setKey.mockResolvedValue(undefined);
lean.mockResolvedValue(null);
});
it('calls next when the user is allowed', async () => {
redisMock.getKey.mockResolvedValue({ part: { edit: true } });
const req = { user: { _id: 'user-1' } };
await checkPermissions('part', 'edit')(req, res, next);
expect(next).toHaveBeenCalledWith();
expect(status).not.toHaveBeenCalled();
});
it('returns 403 when the user is not allowed', async () => {
redisMock.getKey.mockResolvedValue({ part: { edit: false } });
const req = { user: { _id: 'user-1' } };
await checkPermissions('part', 'edit')(req, res, next);
expect(status).toHaveBeenCalledWith(403);
expect(json).toHaveBeenCalledWith({ error: 'Forbidden', code: 'FORBIDDEN' });
expect(next).not.toHaveBeenCalled();
});
});

View File

@ -1,37 +0,0 @@
import { amountWithTax, effectiveMarginPrice } from '../tax.js';
describe('amountWithTax', () => {
it('returns 0 for zero amount', () => {
expect(amountWithTax(0, { rateType: 'percentage', rate: 20 })).toBe(0);
});
it('applies percentage tax', () => {
expect(amountWithTax(100, { rateType: 'percentage', rate: 20 })).toBe(120);
});
it('applies fixed amount tax', () => {
expect(amountWithTax(100, { rateType: 'amount', rate: 5 })).toBe(105);
});
it('applies fixed rate type from API schema', () => {
expect(amountWithTax(100, { rateType: 'fixed', rate: 5 })).toBe(105);
});
it('returns base amount when tax rate is missing', () => {
expect(amountWithTax(100, null)).toBe(100);
});
});
describe('effectiveMarginPrice', () => {
it('computes price from margin mode', () => {
expect(
effectiveMarginPrice({ priceMode: 'margin', price: 50, cost: 100, margin: 20 })
).toBe(120);
});
it('returns explicit price for amount mode', () => {
expect(
effectiveMarginPrice({ priceMode: 'amount', price: 150, cost: 100, margin: 20 })
).toBe(150);
});
});

View File

@ -1,31 +0,0 @@
import { describe, expect, it } from '@jest/globals';
import mongoose from 'mongoose';
import { convertObjectIdStringsInFilter } from '../utils.js';
describe('convertObjectIdStringsInFilter', () => {
it('preserves Date bounds on comparison operators', () => {
const start = new Date('2026-08-04T23:00:00.000Z');
const end = new Date('2026-09-04T22:59:59.999Z');
expect(
convertObjectIdStringsInFilter({
createdAt: { $gte: start, $lte: end },
})
).toEqual({
createdAt: { $gte: start, $lte: end },
});
});
it('converts ObjectId strings while leaving Date values intact', () => {
const start = new Date('2026-08-01T00:00:00.000Z');
const id = '691a537b1e8acd0b40114206';
const result = convertObjectIdStringsInFilter({
owner: id,
createdAt: { $gte: start },
});
expect(result.owner).toEqual(new mongoose.Types.ObjectId(id));
expect(result.createdAt.$gte).toBe(start);
});
});

View File

@ -1,10 +1,11 @@
import mongoose from 'mongoose';
import { userModel } from '../schemas/user.schema.js';
import { dbConnect } from '../mongo/index.js';
import mongoose from "mongoose";
import { userModel } from "../schemas/user.schema.js";
import { dbConnect } from "../mongo/index.js";
async function clear() {
dbConnect();
await userModel.deleteMany({});
console.log("DB cleared");
}
clear().then(() => {

View File

@ -1,54 +0,0 @@
/**
* Convert a value to a CSV cell-friendly format.
* Primitives pass through; objects/arrays are stringified; dates are formatted.
*/
function toCsvValue(val) {
if (val === null || val === undefined) return '';
if (val instanceof Date) return val.toISOString();
if (typeof val === 'number' || typeof val === 'boolean') return String(val);
if (typeof val === 'string') return val;
if (typeof val === 'object') {
if (Array.isArray(val)) return val.map(toCsvValue).join(', ');
return JSON.stringify(val);
}
return String(val);
}
/**
* Escape a CSV field per RFC 4180: wrap in double quotes, escape internal quotes by doubling.
*/
function escapeCsvField(str) {
if (str == null) return '""';
const s = String(str);
if (s.includes('"') || s.includes(',') || s.includes('\n') || s.includes('\r')) {
return '"' + s.replace(/"/g, '""') + '"';
}
return s;
}
/**
* Generate a CSV buffer from tabular data.
* @param {Array<Object>} data - Array of row objects (keys = column headers)
* @param {Object} options - Options
* @param {string[]} [options.columnOrder] - Optional column order (uses Object.keys of first row if not provided)
* @returns {Buffer} CSV file as buffer
*/
export function generateCsvTable(data, options = {}) {
const { columnOrder } = options;
if (!data || data.length === 0) {
return Buffer.from('', 'utf8');
}
const keys = columnOrder || Object.keys(data[0]).filter((k) => !k.startsWith('@'));
const headerRow = keys.map((k) => escapeCsvField(k)).join(',');
const lines = [headerRow];
for (const row of data) {
const values = keys.map((key) => escapeCsvField(toCsvValue(row[key])));
lines.push(values.join(','));
}
const csv = lines.join('\n');
return Buffer.from(csv, 'utf8');
}

File diff suppressed because it is too large Load Diff

View File

@ -1,160 +0,0 @@
import { customAlphabet } from 'nanoid';
import log4js from 'log4js';
import ExcelJS from 'exceljs';
import config from '../config.js';
import { redisServer } from './redis.js';
const logger = log4js.getLogger('Excel');
const EXCEL_TEMP_KEY_PREFIX = 'excel:temp:';
const EXCEL_TEMP_TTL_SECONDS = 15; // 15 seconds
const excelNanoid = customAlphabet(
'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789',
12
);
/**
* Create a temp token and store export params in Redis.
* @param {Object} params - { objectType, filter, sort, order }
* @returns {Promise<{ token: string, url: string }>}
*/
export async function createExcelTempToken(params) {
const baseUrl = config.app?.urlApi?.replace(/\/$/, '') || '';
if (!baseUrl) {
throw new Error('config.app.urlApi is not set; required for Excel temp URLs');
}
const objectType = params.objectType || 'Export';
const now = new Date();
const datetime =
now.getFullYear() +
String(now.getMonth() + 1).padStart(2, '0') +
String(now.getDate()).padStart(2, '0') +
String(now.getHours()).padStart(2, '0') +
String(now.getMinutes()).padStart(2, '0');
const token = `${objectType}s-${datetime}-${excelNanoid()}`;
const key = EXCEL_TEMP_KEY_PREFIX + token;
const stored = { ...params, requestCount: 0 };
await redisServer.setKey(key, stored, EXCEL_TEMP_TTL_SECONDS);
logger.debug('Stored excel temp token in Redis:', key);
const url = `${baseUrl}/excel/temp/${token}.xlsx`;
return { token, url };
}
/**
* Get export params for a temp token (supports up to 2 requests; requestCount stored in Redis).
* @param {string} token
* @returns {Promise<Object|null>} { objectType, filter, sort, order } or null
*/
export async function getExcelTempParams(token) {
if (!token) return null;
const key = EXCEL_TEMP_KEY_PREFIX + token;
const params = await redisServer.getKey(key);
if (!params) {
logger.debug('Excel temp token not found in Redis:', key);
return null;
}
return params;
}
/**
* Convert a value to an Excel cell-friendly format.
* Primitives pass through; objects/arrays are stringified; dates are preserved.
*/
function toExcelValue(val) {
if (val === null || val === undefined) return null;
if (val instanceof Date) return val;
if (typeof val === 'number' || typeof val === 'boolean') return val;
if (typeof val === 'string') return val;
if (typeof val === 'object') {
if (Array.isArray(val)) return val.map(toExcelValue).join(', ');
return JSON.stringify(val);
}
return String(val);
}
/**
* Generate an Excel workbook from tabular data.
* @param {Array<Object>} data - Array of row objects (keys = column headers)
* @param {Object} options - Options
* @param {string} [options.sheetName='Export'] - Worksheet name
* @param {string[]} [options.columnOrder] - Optional column order (uses Object.keys of first row if not provided)
* @returns {Promise<Buffer>} Excel file as buffer
*/
export async function generateExcelTable(data, options = {}) {
const { sheetName = 'Export', columnOrder } = options;
const workbook = new ExcelJS.Workbook();
const worksheet = workbook.addWorksheet(sheetName, {
views: [{ state: 'frozen', ySplit: 1 }],
});
if (!data || data.length === 0) {
const buffer = await workbook.xlsx.writeBuffer();
return Buffer.from(buffer);
}
const keys = columnOrder || Object.keys(data[0]).filter((k) => !k.startsWith('@'));
const colCount = keys.length;
const rowCount = data.length + 1;
const toColLetter = (n) => {
let s = '';
while (n >= 0) {
s = String.fromCharCode((n % 26) + 65) + s;
n = Math.floor(n / 26) - 1;
}
return s;
};
const endCol = toColLetter(colCount - 1);
const tableRows = data.map((row) => keys.map((key) => toExcelValue(row[key])));
worksheet.addTable({
name: 'DataTable',
ref: `A1:${endCol}${rowCount}`,
headerRow: true,
style: {
theme: 'TableStyleLight1',
showRowStripes: true,
},
columns: keys.map((key) => ({ name: key, filterButton: true })),
rows: tableRows,
});
// Auto-fit columns (approximate)
worksheet.columns.forEach((col, i) => {
let maxLen = keys[i]?.length || 10;
worksheet.eachRow({ includeEmpty: false }, (row) => {
const cell = row.getCell(i + 1);
const val = cell.value;
const len = val != null ? String(val).length : 0;
maxLen = Math.min(Math.max(maxLen, len), 50);
});
col.width = maxLen + 2;
});
const buffer = await workbook.xlsx.writeBuffer();
return Buffer.from(buffer);
}
/**
* Increment request count for a temp token. Returns new count or null if token not found.
* @param {string} token
* @returns {Promise<number|null>} New requestCount or null
*/
export async function incrementExcelTempRequestCount(token) {
if (!token) return null;
const key = EXCEL_TEMP_KEY_PREFIX + token;
const data = await redisServer.getKey(key);
if (!data) return null;
const requestCount = (data.requestCount ?? 0) + 1;
const updated = { ...data, requestCount };
await redisServer.setKey(key, updated, EXCEL_TEMP_TTL_SECONDS);
logger.debug('Incremented excel temp request count:', key, requestCount);
return requestCount;
}
export async function deleteExcelTempToken(token) {
const key = EXCEL_TEMP_KEY_PREFIX + token;
await redisServer.deleteKey(key);
logger.debug('Deleted excel temp token from Redis:', key);
}

View File

@ -1,164 +0,0 @@
import log4js from 'log4js';
import mongoose from 'mongoose';
import config from '../config.js';
import { listObjects } from './database.js';
const logger = log4js.getLogger('DatabaseOData');
logger.level = config.server.logLevel;
const EXCLUDED_PATHS = ['appPasswordHash', 'secret', '__v'];
/** Check if path is an embedded object (has nested schema, not ObjectId ref). Excludes arrays. */
function isEmbeddedObject(path) {
if (!path) return false;
if (path.options?.type === mongoose.Schema.Types.ObjectId) return false;
if (path.instance === 'Array') return false;
return path.schema != null || path.caster?.schema != null;
}
/** Get all top-level property keys declared in OData metadata for a schema. */
function getDeclaredPropertyKeys(schema) {
if (!schema?.paths) return [];
return Object.keys(schema.paths)
.filter((k) => !k.includes('.'))
.filter((k) => !EXCLUDED_PATHS.includes(k))
.filter((k) => schema.paths[k]?.options?.select !== false);
}
/**
* Ensure all declared properties are present in each document. OData clients (e.g. Excel)
* expect every metadata property to exist; omit none. Use null for missing/invalid values.
*/
function normalizeODataResponse(data, schema) {
const declaredKeys = getDeclaredPropertyKeys(schema);
if (declaredKeys.length === 0) return data;
function normalize(doc) {
if (!doc || typeof doc !== 'object' || Array.isArray(doc)) return doc;
const result = { ...doc };
for (const key of declaredKeys) {
const val = result[key];
if (isEmbeddedObject(schema.paths[key])) {
// ComplexType: object or null only
const isValid = val != null && typeof val === 'object' && !Array.isArray(val);
result[key] = isValid ? val : null;
} else {
// Primitive, array, etc.: value or null (never omit)
result[key] = val !== undefined && val !== null ? val : null;
}
}
return result;
}
return Array.isArray(data) ? data.map(normalize) : normalize(data);
}
/**
* Convert expanded ObjectId refs ({ _id: x }) back to primitive strings for OData.
* OData metadata declares refs as Edm.String; clients expect primitives, not objects.
*/
function compactObjectIdsForOData(data) {
function isExpandedRef(val) {
return val && typeof val === 'object' && !Array.isArray(val) && Object.keys(val).length === 1 && '_id' in val;
}
function compact(val) {
if (Array.isArray(val)) return val.map(compact);
if (isExpandedRef(val)) return val._id?.toString?.() ?? val._id;
if (val instanceof Date || val instanceof Buffer) return val; // pass through primitives
if (val && typeof val === 'object') {
const result = {};
for (const [k, v] of Object.entries(val)) result[k] = compact(v);
return result;
}
return val;
}
return Array.isArray(data) ? data.map(compact) : compact(data);
}
/**
* Translate filter keys ending with ._id for Mongoose compatibility
* @param {Object} filter - Filter object (will be mutated)
*/
function translateFilterKeys(filter) {
Object.keys(filter).forEach((key) => {
if (key.endsWith('._id')) {
const baseKey = key.slice(0, -4);
filter[baseKey] = filter[key];
delete filter[key];
}
});
}
/**
* List objects with OData-compatible response format.
* Works like listObjects but returns { @odata.context, @odata.count?, value }.
*
* @param {Object} options - Same options as listObjects, plus:
* @param {boolean} [options.count=false] - When true, include @odata.count (total before $skip/$top)
* @returns {Promise<Object>} OData-formatted response or { error, code } on failure
*/
export const listObjectsOData = async ({
model,
populate = [],
page = 1,
limit = 25,
filter = {},
sort = '',
order = 'ascend',
pagination = true,
project,
count = false,
}) => {
try {
const filterCopy = JSON.parse(JSON.stringify(filter));
translateFilterKeys(filterCopy);
const [data, total] = await Promise.all([
listObjects({
model,
populate,
page,
limit,
filter: filterCopy,
sort,
order,
pagination,
project,
}),
count ? model.countDocuments(filterCopy) : Promise.resolve(undefined),
]);
if (data?.error) {
return data;
}
// Strip __v from each document (Mongoose version key - causes OData client issues)
const sanitizedData = Array.isArray(data)
? data.map(({ __v, ...doc }) => doc)
: data;
// Convert expanded ObjectId refs ({ _id: x }) to primitive strings - OData clients expect Edm.String
let odataData = compactObjectIdsForOData(sanitizedData);
// Ensure all declared properties are present; use null for missing values (Excel requires this)
odataData = normalizeODataResponse(odataData, model.schema);
const baseUrl = config.app?.urlApi || '';
const modelName = model.modelName;
const context = `${baseUrl}/odata/$metadata#${modelName}`;
const response = {
'@odata.context': context,
value: odataData,
};
if (count && total !== undefined) {
response['@odata.count'] = total;
}
return response;
} catch (error) {
logger.error('OData list error:', error);
return { error, code: 500 };
}
};

View File

@ -1,153 +0,0 @@
import mongoose from 'mongoose';
import { redisServer } from './redis.js';
import config from '../config.js';
import log4js from 'log4js';
const logger = log4js.getLogger('Permissions');
logger.level = config.server.logLevel;
export const getUserPermissionsCacheKey = (userId) => `permissions:${userId}`;
export const saveUserPermissionsToRedis = async (userId, permissions = {}) => {
if (!userId) {
return;
}
await redisServer.setKey(getUserPermissionsCacheKey(userId), permissions || {});
};
const getUserId = (user) => {
if (!user) return null;
if (typeof user === 'string') return user;
if (user._id) return user._id._id || user._id;
return user;
};
const loadPermissionsFromMongo = async (userId) => {
const userDoc = await mongoose.model('user').findById(userId).select('permissions').lean();
return userDoc?.permissions || {};
};
const countTruePermissions = (obj) => {
if (obj == null || typeof obj !== 'object') {
return 0;
}
return Object.values(obj).reduce((count, value) => {
if (value === true) {
return count + 1;
}
if (value && typeof value === 'object') {
return count + countTruePermissions(value);
}
return count;
}, 0);
};
export const hasPermission = async (user, objectType, action) => {
const userId = getUserId(user);
if (!userId || !objectType || !action) {
return false;
}
logger.debug(`Checking permission: ${userId}, ${objectType}, ${action}`);
const cacheKey = getUserPermissionsCacheKey(userId);
let permissions = await redisServer.getKey(cacheKey);
if (permissions == null) {
permissions = await loadPermissionsFromMongo(userId);
await saveUserPermissionsToRedis(userId, permissions);
}
const allowed = permissions?.[objectType]?.[action] === true;
logger.debug(
`Retrieved permissions for user: ${userId}, Allowed: ${allowed ? 'Yes' : 'No'}, Allowed Count: ${countTruePermissions(permissions)}`
);
return allowed;
};
export const checkPermissions = (objectType, action) => async (req, res, next) => {
try {
if (req.user?._objectType == 'host') {
if (
(objectType === 'documentTemplate' && action === 'design') ||
(objectType === 'file' && action === 'download')
) {
return next();
}
}
const allowed = await hasPermission(req.user, objectType, action);
if (!allowed) {
return res.status(403).json({ error: 'Forbidden', code: 'FORBIDDEN' });
}
return next();
} catch (err) {
return next(err);
}
};
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);
};

View File

@ -1,4 +1,4 @@
import Redis from 'ioredis';
import { createClient } from 'redis';
import log4js from 'log4js';
import config from '../config.js';
@ -7,14 +7,11 @@ 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;
const url = config.database.redis.url || `redis://${config.database.redis.host}:${config.database.redis.port}`;
this.client = new Redis(url, {
password,
lazyConnect: true,
this.client = createClient({
url,
password: config.database.redis.password || undefined,
});
this.client.on('error', (err) => {
@ -36,7 +33,7 @@ class RedisServer {
await this.connect();
const payload = typeof value === 'string' ? value : JSON.stringify(value);
if (ttlSeconds) {
await this.client.set(key, payload, 'EX', ttlSeconds);
await this.client.set(key, payload, { EX: ttlSeconds });
} else {
await this.client.set(key, payload);
}
@ -58,41 +55,17 @@ class RedisServer {
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);
const result = await this.client.scan(cursor, {
MATCH: pattern,
COUNT: 100,
});
cursor = result.cursor;
keys.push(...result.keys);
} while (cursor !== '0');
return keys;
}

View File

@ -1,416 +0,0 @@
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

@ -1,251 +0,0 @@
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

@ -1,124 +0,0 @@
import mongoose from 'mongoose';
import { generateId } from '../../utils.js';
const { Schema } = mongoose;
import { aggregateRollups, aggregateRollupsHistory, editObject } from '../../database.js';
const paymentSchema = new Schema(
{
_reference: { type: String, default: () => generateId()() },
amount: { type: Number, required: true, default: 0 },
vendor: { type: Schema.Types.ObjectId, ref: 'vendor', required: false },
client: { type: Schema.Types.ObjectId, ref: 'client', required: false },
invoice: { type: Schema.Types.ObjectId, ref: 'invoice', required: true },
state: {
type: { type: String, required: true, default: 'draft' },
},
postedAt: { type: Date, required: false },
authorisedAt: { type: Date, required: false },
declinedAt: { type: Date, required: false },
cancelledAt: { type: Date, required: false },
paymentMethod: { type: String, required: false },
notes: { type: String, required: false },
},
{ timestamps: true }
);
paymentSchema.index({ paymentMethod: 'text', notes: 'text' });
const rollupConfigs = [
{
name: 'draft',
filter: { 'state.type': 'draft' },
rollups: [
{ name: 'draftCount', property: 'state.type', operation: 'count' },
{ name: 'draftAmount', property: 'amount', operation: 'sum' },
],
},
{
name: 'posted',
filter: { 'state.type': 'posted' },
rollups: [
{ name: 'postedCount', property: 'state.type', operation: 'count' },
{ name: 'postedAmount', property: 'amount', operation: 'sum' },
],
},
{
name: 'authorised',
filter: { 'state.type': 'authorised' },
rollups: [
{ name: 'authorisedCount', property: 'state.type', operation: 'count' },
{ name: 'authorisedAmount', property: 'amount', operation: 'sum' },
],
},
{
name: 'declined',
filter: { 'state.type': 'declined' },
rollups: [
{ name: 'declinedCount', property: 'state.type', operation: 'count' },
{ name: 'declinedAmount', property: 'amount', operation: 'sum' },
],
},
{
name: 'cancelled',
filter: { 'state.type': 'cancelled' },
rollups: [
{ name: 'cancelledCount', property: 'state.type', operation: 'count' },
{ name: 'cancelledAmount', property: 'amount', operation: 'sum' },
],
},
];
paymentSchema.statics.stats = async function () {
const results = await aggregateRollups({
model: this,
rollupConfigs: rollupConfigs,
});
// Transform the results to match the expected format
return results;
};
paymentSchema.statics.history = async function (from, to) {
const results = await aggregateRollupsHistory({
model: this,
startDate: from,
endDate: to,
rollupConfigs: rollupConfigs,
});
// Return time-series data array
return results;
};
paymentSchema.statics.recalculate = async function (payment, user) {
const paymentId = payment._id || payment;
if (!paymentId) {
return;
}
// For payments, the amount is set directly
const amount = payment.amount || 0;
const updateData = {
amount: parseFloat(amount).toFixed(2),
};
await editObject({
model: this,
id: paymentId,
updateData,
user,
recalculate: false,
});
};
// Add virtual id getter
paymentSchema.virtual('id').get(function () {
return this._id;
});
// Configure JSON serialization to include virtuals
paymentSchema.set('toJSON', { virtuals: true });
// Create and export the model
export const paymentModel = mongoose.model('payment', paymentSchema);

View File

@ -1,25 +0,0 @@
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

@ -1,69 +0,0 @@
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

@ -2,23 +2,15 @@ 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' },
type: { type: String, required: true },
progress: { type: Number, required: false },
},
postedAt: { type: Date, required: false },
startingWeight: {
net: { type: Number, required: true },
gross: { type: Number, required: true },
@ -27,65 +19,17 @@ const filamentStockSchema = new Schema(
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 () {
@ -109,21 +53,6 @@ filamentStockSchema.statics.history = async function (from, to) {
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;

View File

@ -1,139 +1,30 @@
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 { aggregateRollups, editObject } 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 },
item: { type: Schema.Types.ObjectId, refPath: 'itemType', required: true },
syncAmount: { type: String, required: true, 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') {
// Only purchase orders are supported for now
if (orderItem.orderType !== 'purchaseOrder') {
return;
}
@ -142,84 +33,6 @@ orderItemSchema.statics.recalculate = async function (orderItem, user) {
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: {
@ -238,11 +51,6 @@ orderItemSchema.statics.recalculate = async function (orderItem, user) {
},
],
},
{
name: 'overallCount',
rollups: [{ name: 'overallCount', property: '_id', operation: 'count' }],
},
...rollupConfigs,
],
});
@ -250,59 +58,14 @@ orderItemSchema.statics.recalculate = async function (orderItem, user) {
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,
model: purchaseOrderModel,
id: orderId,
updateData: updateData,
updateData: {
totalAmount: parseFloat(totalAmount),
totalAmountWithTax: parseFloat(totalAmountWithTax),
totalTaxAmount: parseFloat(totalAmountWithTax - totalAmount),
},
user,
});
};

View File

@ -1,76 +1,30 @@
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);
};
const { Schema } = mongoose;
import { aggregateRollups, aggregateRollupsHistory } from '../../database.js';
// Define the main partStock schema
const partStockSchema = new mongoose.Schema(
const partStockSchema = new Schema(
{
_reference: { type: String, default: () => generateId()() },
state: {
type: { type: String, required: true, default: 'draft' },
type: { type: String, required: true },
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 },
},
],
sourceType: { type: String, required: true },
source: { type: Schema.Types.ObjectId, refPath: 'sourceType', required: true },
},
{ 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 () {
@ -94,34 +48,6 @@ partStockSchema.statics.history = async function (from, to) {
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;

View File

@ -1,218 +0,0 @@
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

@ -1,116 +1,22 @@
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 },
totalAmount: { type: Number, required: true },
totalAmountWithTax: { type: Number, required: true },
totalTaxAmount: { type: Number, required: true },
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;

View File

@ -1,167 +1,43 @@
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 shipmentItemSchema = new Schema({
itemType: { type: String, required: true },
item: { type: Schema.Types.ObjectId, refPath: 'itemType', required: true },
quantity: { type: Number, required: true },
itemCost: { type: Number, required: true },
totalCost: { type: Number, required: true },
totalCostWithTax: { type: Number, required: true },
taxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
});
const shipmentSchema = new Schema(
{
_reference: { type: String, default: () => generateId()() },
orderType: { type: String, required: true },
order: { type: Schema.Types.ObjectId, refPath: 'orderType', required: true },
purchaseOrder: { type: Schema.Types.ObjectId, ref: 'purchaseOrder', required: true },
vendor: { type: Schema.Types.ObjectId, ref: 'vendor', 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 },
items: [shipmentItemSchema],
cost: { net: { type: Number, required: true }, gross: { type: Number, required: true } },
shippedDate: { type: Date, required: false },
expectedDeliveryDate: { type: Date, required: false },
actualDeliveryDate: { type: Date, required: false },
state: {
type: {
type: String,
required: true,
default: 'pending',
enum: ['pending', 'shipped', 'in_transit', 'delivered', 'cancelled'],
},
},
notes: { type: String },
timestamp: { type: Date, default: Date.now },
},
{ 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;

View File

@ -1,311 +1,40 @@
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 stockAuditItemSchema = new Schema({
type: { type: String, enum: ['filament', 'part'], required: true },
stock: { type: Schema.Types.ObjectId, required: true },
expectedQuantity: { type: Number, required: true },
actualQuantity: { type: Number, required: true },
notes: { type: String },
});
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',
type: { type: String, required: true },
status: {
type: String,
enum: ['pending', 'in_progress', 'completed', 'cancelled'],
default: 'pending',
required: true,
},
stockLocation: {
type: Schema.Types.ObjectId,
ref: 'stockLocation',
required: true,
},
postedAt: { type: Date, required: false },
auditLines: { type: [stockAuditLineSchema], default: [] },
notes: { type: String },
items: [stockAuditItemSchema],
createdBy: { type: Schema.Types.ObjectId, ref: 'user', required: true },
completedAt: { type: Date },
},
{ 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,
});
};
// Add virtual id getter
stockAuditSchema.virtual('id').get(function () {
return this._id;
});
// Configure JSON serialization to include virtuals
stockAuditSchema.set('toJSON', { virtuals: true });
// Create and export the model
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

@ -1,209 +1,7 @@
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()() },
@ -227,83 +25,13 @@ const stockEventSchema = new Schema(
ownerType: {
type: String,
required: true,
enum: ['user', 'subJob', 'stockAudit', 'stockTransfer', 'productStock'],
enum: ['user', 'subJob', 'stockAudit'],
},
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;

View File

@ -1,41 +0,0 @@
import mongoose from 'mongoose';
import { generateId } from '../../utils.js';
const { Schema } = mongoose;
const addressSchema = new Schema({
building: { required: false, type: String },
addressLine1: { required: false, type: String },
addressLine2: { required: false, type: String },
city: { required: false, type: String },
state: { required: false, type: String },
postcode: { required: false, type: String },
country: { required: false, type: String },
});
const stockLocationSchema = new Schema(
{
_reference: { type: String, default: () => generateId()() },
name: { type: String, required: true },
address: { required: false, type: addressSchema },
},
{ timestamps: true }
);
stockLocationSchema.index({ name: 'text' });
stockLocationSchema.statics.stats = async function () {
const total = await this.countDocuments({});
return { total: { count: total } };
};
stockLocationSchema.statics.history = async function () {
return [];
};
stockLocationSchema.virtual('id').get(function () {
return this._id;
});
stockLocationSchema.set('toJSON', { virtuals: true });
export const stockLocationModel = mongoose.model('stockLocation', stockLocationSchema);

View File

@ -1,78 +0,0 @@
import mongoose from 'mongoose';
import { generateId } from '../../utils.js';
const { Schema } = mongoose;
const stockTransferLineSchema = new Schema(
{
fromStockType: {
type: String,
required: true,
enum: ['filamentStock', 'partStock', 'productStock'],
},
fromStock: {
type: Schema.Types.ObjectId,
refPath: 'lines.fromStockType',
required: true,
},
quantity: { type: Number, required: true },
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

@ -1,24 +0,0 @@
import mongoose from 'mongoose';
import { generateId } from '../../utils.js';
const { Schema } = mongoose;
const appPasswordSchema = new mongoose.Schema(
{
_reference: { type: String, default: () => generateId()() },
name: { type: String, required: true },
user: { type: Schema.Types.ObjectId, ref: 'user', required: true },
active: { type: Boolean, required: true, default: true },
secret: { type: String, required: true, select: false },
},
{ timestamps: true }
);
appPasswordSchema.index({ name: 'text' });
appPasswordSchema.virtual('id').get(function () {
return this._id;
});
appPasswordSchema.set('toJSON', { virtuals: true });
export const appPasswordModel = mongoose.model('appPassword', appPasswordSchema);

View File

@ -25,24 +25,17 @@ const auditLogSchema = new Schema(
owner: {
type: Schema.Types.ObjectId,
refPath: 'ownerType',
required: false,
required: true,
},
ownerType: {
type: String,
required: false,
enum: ['user', 'printer', 'host', 'marketplace'],
},
system: {
type: Boolean,
required: true,
default: false,
enum: ['user', 'printer', 'host'],
},
},
{ timestamps: true }
);
auditLogSchema.index({ operation: 'text', parentType: 'text', ownerType: 'text' });
// Add virtual id getter
auditLogSchema.virtual('id').get(function () {
return this._id;

View File

@ -14,15 +14,6 @@ const courierSchema = new mongoose.Schema(
{ timestamps: true }
);
courierSchema.index({
name: 'text',
website: 'text',
email: 'text',
phone: 'text',
contact: 'text',
country: 'text',
});
courierSchema.virtual('id').get(function () {
return this._id;
});

View File

@ -1,18 +1,7 @@
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()() },
@ -22,49 +11,14 @@ const courierServiceSchema = new mongoose.Schema(
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

@ -14,15 +14,8 @@ const documentJobSchema = new Schema(
object: {
type: Schema.Types.ObjectId,
refPath: 'objectType',
required: false,
required: true,
},
objects: [
{
type: Schema.Types.ObjectId,
refPath: 'objectType',
required: false,
},
],
state: {
type: { type: String, required: true, default: 'queued' },
progress: { type: Number, required: false },
@ -38,12 +31,6 @@ const documentJobSchema = new Schema(
ref: 'documentPrinter',
required: true,
},
quantity: {
type: Number,
required: true,
default: 1,
min: 1,
},
content: {
type: String,
required: false,
@ -52,8 +39,6 @@ const documentJobSchema = new Schema(
{ timestamps: true }
);
documentJobSchema.index({ name: 'text', objectType: 'text' });
// Add virtual id getter
documentJobSchema.virtual('id').get(function () {
return this._id;

View File

@ -8,8 +8,6 @@ const connectionSchema = new Schema(
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 }
);
@ -24,8 +22,6 @@ const documentPrinterSchema = new Schema(
},
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 },
@ -34,10 +30,6 @@ const documentPrinterSchema = new Schema(
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 },
@ -46,8 +38,6 @@ const documentPrinterSchema = new Schema(
{ timestamps: true }
);
documentPrinterSchema.index({ name: 'text', tags: 'text' });
// Add virtual id getter
documentPrinterSchema.virtual('id').get(function () {
return this._id;

View File

@ -25,37 +25,10 @@ const documentSizeSchema = new Schema(
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;

View File

@ -2,78 +2,6 @@ import mongoose from 'mongoose';
import { generateId } from '../../utils.js';
const { Schema } = mongoose;
const RESOURCE_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
function normalizeTemplateResourceName(value) {
return String(value ?? '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
function validateTemplateResources(resources) {
const names = new Set();
for (const resource of resources || []) {
if (
!RESOURCE_NAME_PATTERN.test(resource?.name || '') ||
resource?.file == null ||
resource.file === '' ||
names.has(resource.name)
) {
return false;
}
names.add(resource.name);
}
return true;
}
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()() },
@ -111,50 +39,19 @@ const documentTemplateSchema = new Schema(
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',
type: Schema.Types.Mixed,
required: false,
},
resources: {
type: [
new Schema(
{
name: {
type: String,
required: true,
set: normalizeTemplateResourceName,
match: RESOURCE_NAME_PATTERN,
},
file: { type: Schema.Types.ObjectId, ref: 'file', required: true },
},
{ _id: true }
),
],
default: [],
validate: {
validator: validateTemplateResources,
message: 'Template resource names must be unique.',
},
},
},
{ timestamps: true }
);
documentTemplateSchema.index({ name: 'text', tags: 'text', objectType: 'text' });
// Add virtual id getter
documentTemplateSchema.virtual('id').get(function () {
return this._id;
@ -163,50 +60,4 @@ documentTemplateSchema.virtual('id').get(function () {
// 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

@ -1,33 +0,0 @@
import mongoose from 'mongoose';
import { generateId } from '../../utils.js';
const { Schema } = mongoose;
const emailAccountSchema = new Schema(
{
_reference: { type: String, default: () => generateId()() },
name: { type: String, required: true, unique: true },
active: { type: Boolean, required: true, default: true },
host: { type: String, required: true },
port: { type: Number, required: true, default: 587, min: 1, max: 65535 },
secure: { type: Boolean, required: true, default: false },
username: { type: String, required: false },
password: { type: String, required: false, select: false },
fromName: { type: String, required: true },
fromEmail: { type: String, required: true },
},
{ timestamps: true }
);
emailAccountSchema.index({ name: 'text', host: 'text', fromEmail: 'text' });
emailAccountSchema.virtual('id').get(function () {
return this._id;
});
function omitPassword(_document, value) {
delete value.password;
return value;
}
emailAccountSchema.set('toJSON', { virtuals: true, transform: omitPassword });
emailAccountSchema.set('toObject', { virtuals: true, transform: omitPassword });
export const emailAccountModel = mongoose.model('emailAccount', emailAccountSchema);

View File

@ -1,77 +0,0 @@
import mongoose from 'mongoose';
import { generateId } from '../../utils.js';
const { Schema } = mongoose;
const emailRecipientSchema = new Schema(
{
recipientEmail: { type: String, required: true },
recipientType: { type: String, enum: ['client', 'vendor'], required: false },
recipient: {
type: Schema.Types.ObjectId,
refPath: 'recipients.recipientType',
required: false,
},
objectType: { type: String, required: false },
object: {
type: Schema.Types.ObjectId,
refPath: 'recipients.objectType',
required: false,
},
pixelId: { type: String, required: true },
messageId: { type: String, required: false },
attachments: [{ type: Schema.Types.ObjectId, ref: 'file', required: false }],
read: { type: Boolean, required: true, default: false },
sent: { type: Boolean, required: true, default: false },
sentAt: { type: Date, required: false },
readAt: { type: Date, required: false },
},
{ _id: true }
);
const emailMessageSchema = new Schema(
{
_reference: { type: String, default: () => generateId()() },
name: { type: String, required: true },
emailTemplate: {
type: Schema.Types.ObjectId,
ref: 'emailTemplate',
required: true,
},
objectType: { type: String, required: true },
object: { type: Schema.Types.ObjectId, refPath: 'objectType', required: false },
objects: [{ type: Schema.Types.ObjectId, refPath: 'objectType', required: false }],
emailAccount: {
type: Schema.Types.ObjectId,
ref: 'emailAccount',
required: true,
},
recipients: { type: [emailRecipientSchema], required: true, default: [] },
fromEmail: { type: String, required: true, immutable: true },
subject: { type: String, required: false, default: '' },
content: { type: String, required: false, default: '' },
state: {
type: { type: String, required: true, default: 'queued' },
progress: { type: Number, required: false, default: 0 },
message: { type: String, required: false },
},
sentAt: { type: Date, required: false },
firstReadAt: { type: Date, required: false },
lastReadAt: { type: Date, required: false },
},
{ timestamps: true, suppressReservedKeysWarning: true }
);
emailMessageSchema.index({
name: 'text',
fromEmail: 'text',
objectType: 'text',
'recipients.recipientEmail': 'text',
});
emailMessageSchema.index({ 'recipients.pixelId': 1 }, { unique: true, sparse: true });
emailMessageSchema.virtual('id').get(function () {
return this._id;
});
emailMessageSchema.set('toJSON', { virtuals: true });
export const emailMessageModel = mongoose.model('emailMessage', emailMessageSchema);

View File

@ -1,123 +0,0 @@
import mongoose from 'mongoose';
import { generateId } from '../../utils.js';
const { Schema } = mongoose;
const RESOURCE_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
function normalizeTemplateResourceName(value) {
return String(value ?? '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
function validateTemplateResources(resources) {
const names = new Set();
for (const resource of resources || []) {
if (
!RESOURCE_NAME_PATTERN.test(resource?.name || '') ||
resource?.file == null ||
resource.file === '' ||
names.has(resource.name)
) {
return false;
}
names.add(resource.name);
}
return true;
}
const RENDER_EMAIL_TEMPLATE_CALL = /fc\.renderEmailTemplate\s*\(\s*(['"])([^'"]+)\1/g;
function extractReferences(content) {
const references = [];
const seen = new Set();
for (const match of String(content || '').matchAll(RENDER_EMAIL_TEMPLATE_CALL)) {
const reference = match[2]?.trim();
if (reference && !seen.has(reference)) {
seen.add(reference);
references.push(reference);
}
}
return references;
}
const emailTemplateAttachmentSchema = new Schema(
{
type: { type: String, enum: ['file', 'documentTemplate'], required: true },
file: { type: Schema.Types.ObjectId, refPath: 'attachments.type', required: true },
fileName: { type: String, required: true },
fileType: { type: String, enum: ['pdf', 'jpeg', 'png', 'svg'], required: false },
},
{ _id: true }
);
const emailTemplateSchema = 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: 'emailTemplate', required: false },
referencedTemplates: [{ type: Schema.Types.ObjectId, ref: 'emailTemplate', required: false }],
subject: { type: String, required: false, default: '' },
content: { type: String, required: false, default: '<Container></Container>' },
testObject: { type: Schema.Types.ObjectId, refPath: 'objectType', required: false },
attachments: { type: [emailTemplateAttachmentSchema], default: [] },
resources: {
type: [
new Schema(
{
name: {
type: String,
required: true,
set: normalizeTemplateResourceName,
match: RESOURCE_NAME_PATTERN,
},
file: { type: Schema.Types.ObjectId, ref: 'file', required: true },
},
{ _id: true }
),
],
default: [],
validate: {
validator: validateTemplateResources,
message: 'Template resource names must be unique.',
},
},
},
{ timestamps: true }
);
emailTemplateSchema.index({ name: 'text', tags: 'text', objectType: 'text' });
emailTemplateSchema.virtual('id').get(function () {
return this._id;
});
emailTemplateSchema.set('toJSON', { virtuals: true });
emailTemplateSchema.statics.recalculate = async function (emailTemplate, user) {
const id = emailTemplate?._id || emailTemplate;
if (!id || !(await this.exists({ _id: id }))) return;
const referencedTemplates = [];
for (const reference of extractReferences(emailTemplate?.content)) {
const normalizedReference = reference.replace(/^ETP:/i, '').trim();
const found = await this.findOne({ _reference: normalizedReference }).select('_id').lean();
if (found?._id) referencedTemplates.push(found._id);
}
const { editObject } = await import('../../database.js');
await editObject({
model: this,
id,
updateData: { referencedTemplates },
user,
populate: [{ path: 'referencedTemplates', strictPopulate: false }],
recalculate: false,
});
};
export const emailTemplateModel = mongoose.model('emailTemplate', emailTemplateSchema);

View File

@ -1,28 +1,25 @@
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 },
color: { required: true, type: String },
vendor: { type: Schema.Types.ObjectId, ref: 'vendor', required: true },
type: { required: true, type: String },
cost: { required: true, type: Number },
costTaxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: true },
costWithTax: { required: true, type: Number },
diameter: { required: true, type: Number },
density: { required: true, type: Number },
createdAt: { required: true, type: Date },
updatedAt: { required: true, type: Date },
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;
@ -30,30 +27,4 @@ filamentSchema.virtual('id').get(function () {
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

@ -1,82 +0,0 @@
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

@ -9,13 +9,10 @@ const fileSchema = new mongoose.Schema(
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;
});

View File

@ -10,12 +10,20 @@ const deviceInfoSchema = new mongoose.Schema(
release: { type: String },
arch: { type: String },
hostname: { type: String },
uptime: { type: Number },
},
cpu: {
cores: { type: Number },
model: { type: String },
speedMHz: { type: Number },
},
memory: {
totalGB: { type: String }, // stored as string from .toFixed(2), could also use Number
freeGB: { type: String },
},
network: {
type: mongoose.Schema.Types.Mixed, // since it's an object with dynamic interface names
},
user: {
uid: { type: Number },
gid: { type: Number },
@ -25,6 +33,8 @@ const deviceInfoSchema = new mongoose.Schema(
},
process: {
nodeVersion: { type: String },
pid: { type: Number },
cwd: { type: String },
execPath: { type: String },
},
},
@ -45,16 +55,12 @@ const hostSchema = new mongoose.Schema(
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 },
deviceInfo: { deviceInfoSchema },
files: [{ type: mongoose.Schema.Types.ObjectId, ref: 'file' }],
},
{ timestamps: true }
);
hostSchema.index({ name: 'text', tags: 'text' });
hostSchema.virtual('id').get(function () {
return this._id;
});

View File

@ -1,17 +1,13 @@
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' });
const materialSchema = new mongoose.Schema({
_reference: { type: String, default: () => generateId()() },
name: { required: true, type: String },
url: { required: false, type: String },
image: { required: false, type: Buffer },
tags: [{ type: String }],
});
materialSchema.virtual('id').get(function () {
return this._id;

View File

@ -23,8 +23,6 @@ const noteTypeSchema = new Schema(
{ timestamps: true }
);
noteTypeSchema.index({ name: 'text' });
// Add virtual id getter
noteTypeSchema.virtual('id').get(function () {
return this._id;

View File

@ -1,31 +1,27 @@
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
// Define the main part schema
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 },
fileName: { type: String, required: false },
priceMode: { type: String, default: 'margin' },
price: { type: Number, required: true },
cost: { type: Number, required: true },
margin: { type: Number, required: false },
amount: { type: Number, required: false },
costTaxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
vendor: { type: Schema.Types.ObjectId, ref: 'vendor', required: true },
file: { type: mongoose.SchemaTypes.ObjectId, ref: 'file', required: false },
priceTaxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
costWithTax: { type: Number, required: false },
costTaxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
priceWithTax: { type: Number, required: false },
costWithTax: { type: Number, required: false },
},
{ timestamps: true }
);
partSchema.index({ name: 'text' });
// Add virtual id getter
partSchema.virtual('id').get(function () {
return this._id;
@ -34,35 +30,5 @@ partSchema.virtual('id').get(function () {
// 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

@ -1,112 +0,0 @@
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

@ -1,62 +0,0 @@
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

@ -1,34 +1,29 @@
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;
const partSchema = new Schema({
part: { type: Schema.Types.ObjectId, ref: 'part', required: true },
quantity: { type: Number, required: true },
});
// 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 },
vendor: { type: Schema.Types.ObjectId, ref: 'vendor', required: true },
parts: [partSchema],
priceTaxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
costWithTax: { type: Number, required: false },
priceWithTax: { type: Number, required: false },
costTaxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
},
{ timestamps: true }
);
productSchema.index({ name: 'text', tags: 'text', version: 'text' });
// Add virtual id getter
productSchema.virtual('id').get(function () {
return this._id;
@ -37,51 +32,5 @@ productSchema.virtual('id').get(function () {
// 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

@ -1,30 +0,0 @@
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

@ -1,119 +0,0 @@
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

@ -1,108 +0,0 @@
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

@ -1,6 +1,5 @@
import mongoose from 'mongoose';
import { generateId } from '../../utils.js';
import { marketplaceSyncMappingSchema } from '../sales/marketplaceMapping.schema.js';
const taxRateSchema = new mongoose.Schema(
{
@ -11,17 +10,12 @@ const taxRateSchema = new mongoose.Schema(
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;
});

View File

@ -0,0 +1,28 @@
import mongoose from 'mongoose';
import { generateId } from '../../utils.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.virtual('id').get(function () {
return this._id;
});
taxRecordSchema.set('toJSON', { virtuals: true });
export const taxRecordModel = mongoose.model('taxRecord', taxRecordSchema);

View File

@ -1,13 +1,5 @@
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(
{
@ -18,47 +10,14 @@ const userSchema = new mongoose.Schema(
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

@ -1,74 +0,0 @@
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

@ -26,8 +26,6 @@ const vendorSchema = new mongoose.Schema(
{ timestamps: true }
);
vendorSchema.index({ name: 'text', website: 'text', email: 'text', phone: 'text', contact: 'text', country: 'text' });
vendorSchema.virtual('id').get(function () {
return this._id;
});

View File

@ -1,51 +0,0 @@
import mongoose from 'mongoose';
import { generateId } from '../../utils.js';
const { Schema } = mongoose;
const notificationSchema = new mongoose.Schema({
_reference: { type: String, default: () => generateId()() },
user: {
type: Schema.Types.ObjectId,
ref: 'user',
required: true,
},
title: {
type: String,
required: true,
},
message: {
type: String,
required: true,
},
type: {
type: String,
required: true,
default: 'info',
},
read: {
type: Boolean,
default: false,
},
metadata: {
type: Object,
required: false,
},
createdAt: {
type: Date,
required: true,
default: Date.now,
},
updatedAt: {
type: Date,
required: true,
default: Date.now,
},
});
notificationSchema.virtual('id').get(function () {
return this._id;
});
notificationSchema.set('toJSON', { virtuals: true });
export const notificationModel = mongoose.model('notification', notificationSchema);

View File

@ -1,58 +0,0 @@
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

@ -1,44 +0,0 @@
import mongoose from 'mongoose';
import { generateId } from '../../utils.js';
const { Schema } = mongoose;
const userNotifierSchema = new mongoose.Schema({
_reference: { type: String, default: () => generateId()() },
user: {
type: Schema.Types.ObjectId,
ref: 'user',
required: true,
},
email: {
type: Boolean,
required: true,
default: false,
},
object: {
type: Schema.Types.ObjectId,
refPath: 'objectType',
required: true,
},
objectType: {
type: String,
required: true,
},
createdAt: {
type: Date,
required: true,
default: Date.now,
},
updatedAt: {
type: Date,
required: true,
default: Date.now,
},
});
userNotifierSchema.virtual('id').get(function () {
return this._id;
});
userNotifierSchema.set('toJSON', { virtuals: true });
export const userNotifierModel = mongoose.model('userNotifier', userNotifierSchema);

View File

@ -1,47 +0,0 @@
import mongoose from 'mongoose';
import { generateId } from '../../utils.js';
const { Schema } = mongoose;
const userSettingsSchema = new mongoose.Schema({
_reference: { type: String, default: () => generateId()() },
user: {
type: Schema.Types.ObjectId,
ref: 'user',
required: true,
unique: true,
},
defaults: {
viewMode: { type: Schema.Types.Mixed, default: () => ({}) },
filterSidebarVisibility: {
type: Schema.Types.Mixed,
default: () => ({}),
},
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

@ -1,138 +1,101 @@
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 { emailAccountModel } from './management/emailaccount.schema.js';
import { emailTemplateModel } from './management/emailtemplate.schema.js';
import { emailMessageModel } from './management/emailmessage.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';
import { taxRateModel } from './management/taxrates.schema.js';
import { taxRecordModel } from './management/taxrecord.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.
// Map prefixes to models and id fields
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'),
EAC: modelEntry(() => emailAccountModel, 'emailAccount', 'Email Account'),
ETP: modelEntry(() => emailTemplateModel, 'emailTemplate', 'Email Template'),
EMS: modelEntry(() => emailMessageModel, 'emailMessage', 'Email Message'),
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'),
PRN: { model: printerModel, idField: '_id', type: 'printer', referenceField: '_reference' },
FIL: { model: filamentModel, idField: '_id', type: 'filament', referenceField: '_reference' },
GCF: { model: gcodeFileModel, idField: '_id', type: 'gcodeFile', referenceField: '_reference' },
JOB: { model: jobModel, idField: '_id', type: 'job', referenceField: '_reference' },
PRT: { model: partModel, idField: '_id', type: 'part', referenceField: '_reference' },
PRD: { model: productModel, idField: '_id', type: 'product', referenceField: '_reference' },
VEN: { model: vendorModel, idField: '_id', type: 'vendor', referenceField: '_reference' },
SJB: { model: subJobModel, idField: '_id', type: 'subJob', referenceField: '_reference' },
FLS: {
model: filamentStockModel,
idField: '_id',
type: 'filamentStock',
referenceField: '_reference',
},
SEV: { model: stockEventModel, idField: '_id', type: 'stockEvent', referenceField: '_reference' },
SAU: { model: stockAuditModel, idField: '_id', type: 'stockAudit', referenceField: '_reference' },
PTS: { model: partStockModel, idField: '_id', type: 'partStock', referenceField: '_reference' },
PDS: { model: null, idField: '_id', type: 'productStock', referenceField: '_reference' }, // No productStockModel found
ADL: { model: auditLogModel, idField: '_id', type: 'auditLog', referenceField: '_reference' },
USR: { model: userModel, idField: '_id', type: 'user', referenceField: '_reference' },
NTY: { model: noteTypeModel, idField: '_id', type: 'noteType', referenceField: '_reference' },
NTE: { model: noteModel, idField: '_id', type: 'note', referenceField: '_reference' },
DSZ: {
model: documentSizeModel,
idField: '_id',
type: 'documentSize',
referenceField: '_reference',
},
DTP: {
model: documentTemplateModel,
idField: '_id',
type: 'documentTemplate',
referenceField: '_reference',
},
DPR: {
model: documentPrinterModel,
idField: '_id',
type: 'documentPrinter',
referenceField: '_reference',
},
DJB: {
model: documentJobModel,
idField: '_id',
type: 'documentJob',
referenceField: '_reference',
},
HST: { model: hostModel, idField: '_id', type: 'host', referenceField: '_reference' },
FLE: { model: fileModel, idField: '_id', type: 'file', referenceField: '_reference' },
POR: {
model: purchaseOrderModel,
idField: '_id',
type: 'purchaseOrder',
referenceField: '_reference',
},
ODI: {
model: orderItemModel,
idField: '_id',
type: 'orderItem',
referenceField: '_reference',
},
COS: {
model: courierServiceModel,
idField: '_id',
type: 'courierService',
referenceField: '_reference',
},
COR: { model: courierModel, idField: '_id', type: 'courier', referenceField: '_reference' },
TXR: { model: taxRateModel, idField: '_id', type: 'taxRate', referenceField: '_reference' },
TXD: { model: taxRecordModel, idField: '_id', type: 'taxRecord', referenceField: '_reference' },
};

View File

@ -1,165 +0,0 @@
import mongoose from 'mongoose';
import { generateId } from '../../utils.js';
const filamentProfileSchema = new mongoose.Schema(
{
_reference: { type: String, default: () => generateId()() },
name: { type: String, required: true },
filamentType: { type: String, required: true },
filament: {
type: mongoose.Schema.Types.ObjectId,
refPath: 'filamentType',
required: true,
},
filamentIsSupport: { type: Boolean },
filamentSoluble: { type: Boolean },
filamentPrintable: { type: Number },
filamentAdhesivenessCategory: { type: Number },
temperatureVitrification: { type: Number },
idleTemperature: { type: Number },
pelletFlowCoefficient: { type: Number },
requiredNozzleHrc: { type: Number },
filamentFlowRatio: { type: Number },
enablePressureAdvance: { type: Boolean },
pressureAdvance: { type: Number },
adaptivePressureAdvance: { type: Boolean },
adaptivePressureAdvanceBridges: { type: Boolean },
adaptivePressureAdvanceOverhangs: { type: Boolean },
adaptivePressureAdvanceModel: { type: String },
activateChamberTempControl: { type: Boolean },
chamberTemperature: { type: Number },
chamberMinimalTemperature: { type: Number },
nozzleTemperatureInitialLayer: { type: Number },
nozzleTemperature: { type: Number },
nozzleTemperatureRangeLow: { type: Number },
nozzleTemperatureRangeHigh: { type: Number },
hotPlateTempInitialLayer: { type: Number },
hotPlateTemp: { type: Number },
coolPlateTempInitialLayer: { type: Number },
coolPlateTemp: { type: Number },
engPlateTempInitialLayer: { type: Number },
engPlateTemp: { type: Number },
texturedPlateTempInitialLayer: { type: Number },
texturedPlateTemp: { type: Number },
texturedCoolPlateTempInitialLayer: { type: Number },
texturedCoolPlateTemp: { type: Number },
supertackPlateTempInitialLayer: { type: Number },
supertackPlateTemp: { type: Number },
filamentAdaptiveVolumetricSpeed: { type: Boolean },
filamentMaxVolumetricSpeed: { type: Number },
volumetricSpeedCoefficients: { type: String },
closeFanTheFirstXLayers: { type: Number },
fullFanSpeedLayer: { type: Number },
fanMinSpeed: { type: Number },
fanMaxSpeed: { type: Number },
reduceFanStopStartFreq: { type: Boolean },
slowDownForLayerCooling: { type: Boolean },
dontSlowDownOuterWall: { type: Boolean },
slowDownMinSpeed: { type: Number },
slowDownLayerTime: { type: Number },
fanCoolingLayerTime: { type: Number },
enableOverhangBridgeFan: { type: Boolean },
overhangFanThreshold: { type: String },
overhangFanSpeed: { type: Number },
internalBridgeFanSpeed: { type: Number },
supportMaterialInterfaceFanSpeed: { type: Number },
ironingFanSpeed: { type: Number },
initialLayerFanSpeed: { type: Number },
firstXLayerFanSpeed: { type: Number },
additionalCoolingFanSpeed: { type: Number },
additionalFanFullSpeedLayer: { type: Number },
closeAdditionalFanFirstXLayers: { type: Boolean },
activateAirFiltration: { type: Boolean },
activateAirFiltrationDuringPrint: { type: Boolean },
activateAirFiltrationOnCompletion: { type: Boolean },
duringPrintExhaustFanSpeed: { type: Number },
completePrintExhaustFanSpeed: { type: Number },
filamentRetractionLength: { type: Number },
filamentRetractionSpeed: { type: Number },
filamentDeretractionSpeed: { type: Number },
filamentRetractionMinimumTravel: { type: Number },
filamentRetractWhenChangingLayer: { type: Boolean },
filamentRetractBeforeWipe: { type: Number },
filamentRetractAfterWipe: { type: Number },
filamentRetractRestartExtra: { type: Number },
filamentRetractLiftAbove: { type: Number },
filamentRetractLiftBelow: { type: Number },
filamentRetractLiftEnforce: { type: String },
filamentWipe: { type: Boolean },
filamentWipeDistance: { type: Number },
filamentZHop: { type: Number },
filamentZHopTypes: { type: String },
filamentLongRetractionsWhenCut: { type: Boolean },
filamentRetractionDistancesWhenCut: { type: Number },
longRetractionsWhenEc: { type: Boolean },
retractionDistancesWhenEc: { type: Number },
filamentLoadingSpeed: { type: Number },
filamentLoadingSpeedStart: { type: Number },
filamentUnloadingSpeed: { type: Number },
filamentUnloadingSpeedStart: { type: Number },
filamentChangeLength: { type: Number },
filamentChangeLengthNc: { type: Number },
filamentToolchangeDelay: { type: Number },
filamentExtruderCompatibility: { type: Number },
filamentExtruderVariant: { type: String },
filamentMultitoolRamming: { type: Boolean },
filamentMultitoolRammingFlow: { type: Number },
filamentMultitoolRammingVolume: { type: Number },
filamentRammingParameters: { type: String },
filamentRammingTravelTime: { type: Number },
filamentRammingTravelTimeNc: { type: Number },
filamentRammingVolumetricSpeed: { type: Number },
filamentRammingVolumetricSpeedNc: { type: Number },
filamentMinimalPurgeOnWipeTower: { type: Number },
filamentTowerInterfacePreExtrusionDist: { type: Number },
filamentTowerInterfacePreExtrusionLength: { type: Number },
filamentTowerInterfacePrintTemp: { type: Number },
filamentTowerInterfacePurgeVolume: { type: Number },
filamentTowerIroningArea: { type: Number },
filamentCoolingBeforeTower: { type: Number },
filamentCoolingInitialSpeed: { type: Number },
filamentCoolingFinalSpeed: { type: Number },
filamentCoolingMoves: { type: Number },
filamentFlushTemp: { type: Number },
filamentFlushTempFast: { type: Number },
filamentFlushVolumetricSpeed: { type: Number },
filamentPreCoolingTemperature: { type: Number },
filamentPreCoolingTemperatureNc: { type: Number },
filamentPreheatTemperatureDelta: { type: Number },
filamentPrimeVolumeNc: { type: Number },
filamentRetractLengthNc: { type: Number },
filamentStampingDistance: { type: Number },
filamentStampingLoadingSpeed: { type: Number },
filamentStartGcode: { type: String },
filamentEndGcode: { type: String },
filamentChangeExtrusionRoleGcode: { type: String },
filamentShrink: { type: String },
filamentShrinkageCompensationZ: { type: String },
filamentDevAmsDryingTemperature: { type: Number },
filamentDevAmsDryingTime: { type: Number },
filamentDevAmsDryingHeatDistortionTemperature: { type: Number },
filamentDevAmsDryingAmsLimitations: { type: String },
filamentDevChamberDryingBedTemperature: { type: Number },
filamentDevChamberDryingTime: { type: Number },
filamentDevDryingCoolingTemperature: { type: Number },
filamentDevDryingSofteningTemperature: { type: Number },
compatiblePrinters: [
{ type: mongoose.Schema.Types.ObjectId, ref: 'printerProfile' },
],
compatiblePrintersCondition: { type: String },
compatiblePrints: [{ type: String }],
compatiblePrintsCondition: { type: String },
filamentNotes: { type: String },
},
{ timestamps: true }
);
filamentProfileSchema.index({ name: 'text', filamentNotes: 'text' });
filamentProfileSchema.virtual('id').get(function () {
return this._id;
});
filamentProfileSchema.set('toJSON', { virtuals: true });
export const filamentProfileModel = mongoose.model('filamentProfile', filamentProfileSchema);

View File

@ -4,7 +4,6 @@ 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 },
});
@ -15,7 +14,6 @@ const gcodeFileSchema = new mongoose.Schema(
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 },
@ -23,22 +21,7 @@ const gcodeFileSchema = new mongoose.Schema(
{ 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.index({ name: 'text', brand: 'text' });
gcodeFileSchema.virtual('id').get(function () {
return this._id;

View File

@ -1,15 +1,7 @@
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';
import { aggregateRollups, aggregateRollupsHistory } from '../../database.js';
const jobSchema = new mongoose.Schema(
{
@ -40,8 +32,6 @@ const jobSchema = new mongoose.Schema(
{ timestamps: true }
);
jobSchema.index({ 'state.type': 'text' });
const rollupConfigs = [
{
name: 'queued',
@ -92,95 +82,6 @@ jobSchema.statics.history = async function (from, to) {
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;
});

View File

@ -1,7 +1,7 @@
import mongoose from 'mongoose';
import { generateId } from '../../utils.js';
const { Schema } = mongoose;
import { aggregateRollups, aggregateRollupsHistory, editObject } from '../../database.js';
import { aggregateRollups, aggregateRollupsHistory } from '../../database.js';
// Define the moonraker connection schema
const moonrakerSchema = new Schema(
@ -19,25 +19,12 @@ 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 },
_id: { type: String, required: true },
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(
{
@ -66,13 +53,10 @@ const printerSchema = new Schema(
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',
@ -108,6 +92,8 @@ printerSchema.statics.stats = async function () {
rollupConfigs: rollupConfigs,
});
console.log(results);
// Transform the results to match the expected format
return results;
};
@ -124,28 +110,6 @@ printerSchema.statics.history = async function (from, to) {
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;

View File

@ -1,111 +0,0 @@
import mongoose from 'mongoose';
import { generateId } from '../../utils.js';
const minMaxNumberSchema = {
min: { type: Number },
max: { type: Number }
};
const thumbnailSchema = new mongoose.Schema(
{
width: { type: Number },
height: { type: Number }
},
{ _id: true }
);
const bedExcludeAreaSchema = new mongoose.Schema(
{
x: { type: Number },
y: { type: Number }
},
{ _id: true }
);
const extruderSchema = new mongoose.Schema(
{
defaultNozzleVolumeType: { type: String },
nozzleDiameter: { type: Number },
nozzleVolume: { type: Number },
nozzleType: { type: String },
nozzleFlushDataset: { type: String },
minLayerHeight: { type: Number },
maxLayerHeight: { type: Number },
extruderColour: { type: String },
extruderType: { type: String },
extruderVariantList: { type: String },
extruderPrintableHeight: { type: Number },
printerExtruderId: { type: String },
printerExtruderVariant: { type: String },
positionOffsetX: { type: Number },
positionOffsetY: { type: Number },
retractionLength: { type: Number },
retractionExtraLengthOnRestart: { type: Number },
retractionSpeed: { type: Number },
deretractionSpeed: { type: Number },
retractionTravelDistanceThreshold: { type: Number },
retractOnLayerChange: { type: Boolean },
wipeWhileRetracting: { type: Boolean },
wipeDistance: { type: Number },
retractAmountBeforeWipe: { type: Number },
retractAmountAfterWipe: { type: Number },
zHopOnSurfaces: { type: String },
zHopType: { type: String },
zHopHeight: { type: Number },
zHopTravelingAngle: { type: Number },
zHopOnlyLiftZAbove: { type: Number },
zHopOnlyLiftZBelow: { type: Number },
materialSwitchRetractionLength: { type: Number },
materialSwitchExtraLengthOnRestart: { type: Number },
longRetractionWhenCut: { type: Boolean },
retractionDistancesWhenCut: { type: Number },
},
{ _id: true }
);
const printerProfileSchema = new mongoose.Schema(
{
_reference: { type: String, default: () => generateId()() },
name: { type: String, required: true },
printer: { type: mongoose.Schema.Types.ObjectId, ref: 'printer', default: null },
extruders: [extruderSchema],
printBedWidth: { type: Number },
printBedHeight: { type: Number },
originX: { type: Number },
originY: { type: Number },
bedExcludeArea: [bedExcludeAreaSchema],
printableHeight: { type: Number },
supportMultiBedTypes: { type: Boolean },
bestObjectPositionX: { type: Number },
bestObjectPositionY: { type: Number },
zOffset: { type: Number },
preferredOrientation: { type: Number },
machineMaxAccelerationRetracting: minMaxNumberSchema,
machineMaxSpeedE: minMaxNumberSchema,
machineMaxSpeedX: minMaxNumberSchema,
machineMaxSpeedY: minMaxNumberSchema,
machinePauseGcode: { type: String },
machineStartGcode: { type: String },
machineEndGcode: { type: String },
layerChangeGcode: { type: String },
beforeLayerChangeGcode: { type: String },
printerNotes: { type: String },
scanFirstLayer: { type: Boolean },
machineLoadFilamentTime: { type: Number },
machineUnloadFilamentTime: { type: Number },
thumbnails: [thumbnailSchema],
auxiliaryFan: { type: Boolean },
machineMaxJunctionDeviation: minMaxNumberSchema,
},
{ timestamps: true }
);
printerProfileSchema.index({ name: 'text', printerNotes: 'text' });
printerProfileSchema.virtual('id').get(function () {
return this._id;
});
printerProfileSchema.set('toJSON', { virtuals: true });
export const printerProfileModel = mongoose.model('printerProfile', printerProfileSchema);

View File

@ -43,8 +43,6 @@ const subJobSchema = new mongoose.Schema({
finishedAt: { required: false, type: Date, default: null },
});
subJobSchema.index({ moonrakerJobId: 'text', 'state.type': 'text' });
subJobSchema.virtual('id').get(function () {
return this._id;
});

View File

@ -1,73 +0,0 @@
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

@ -1,31 +0,0 @@
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

@ -1,260 +0,0 @@
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

@ -1,171 +0,0 @@
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

@ -1,125 +0,0 @@
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

@ -1,24 +0,0 @@
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

@ -1,31 +0,0 @@
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

@ -1,46 +0,0 @@
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

@ -1,229 +0,0 @@
import mongoose from 'mongoose';
import { generateId } from '../../utils.js';
const { Schema } = mongoose;
import { aggregateRollups, aggregateRollupsHistory, editObject } from '../../database.js';
const salesOrderSchema = new Schema(
{
_reference: { type: String, default: () => generateId()() },
totalAmount: { type: Number, required: true, default: 0 },
totalAmountWithTax: { type: Number, required: true, default: 0 },
shippingAmount: { type: Number, required: true, default: 0 },
shippingAmountWithTax: { type: Number, required: true, default: 0 },
grandTotalAmount: { type: Number, required: true, default: 0 },
totalTaxAmount: { type: Number, required: true, default: 0 },
timestamp: { type: Date, default: Date.now },
client: { type: Schema.Types.ObjectId, ref: 'client', required: true },
marketplace: { type: Schema.Types.ObjectId, ref: 'marketplace', required: false },
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);

View File

@ -1,54 +0,0 @@
/**
* 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;
}

Some files were not shown because too many files have changed in this diff Show More