Compare commits

...

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

195 changed files with 890106 additions and 30473 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

128
Jenkinsfile vendored
View File

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

View File

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

View File

@ -9,17 +9,15 @@
"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": {
@ -48,77 +46,6 @@
"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": {
@ -136,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": "",
@ -165,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',
},
},
],
};

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

@ -12,16 +12,13 @@
"body-parser": "^2.2.0",
"canonical-json": "^0.2.0",
"cors": "^2.8.5",
"diff": "^9.0.0",
"dotenv": "^17.2.3",
"exceljs": "^4.4.0",
"exifr": "^7.1.3",
"express": "^5.1.0",
"express-session": "^1.18.2",
"i": "^0.3.7",
"jsonwebtoken": "^9.0.2",
"keycloak-connect": "^26.1.1",
"lodash": "^4.17.23",
"log4js": "^6.9.1",
"mongodb": "^6.21.0",
"mongoose": "^8.19.4",
@ -32,7 +29,6 @@
"nodemailer": "*",
"nodemon": "^3.1.11",
"pg": "^8.16.3",
"puppeteer": "^24.37.5",
"redis": "^5.10.0",
"sequelize": "^6.37.7"
},
@ -45,29 +41,22 @@
"@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"

10828
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -1,71 +0,0 @@
import { jest } from '@jest/globals';
import request from 'supertest';
// Mock Keycloak and Auth
jest.unstable_mockModule('../keycloak.js', () => ({
keycloak: {
middleware: () => (req, res, next) => next(),
protect: () => (req, res, next) => next(),
},
isAuthenticated: (req, res, next) => next(),
isAppAuthenticated: (req, res, next) => next(),
isAppQueryAuthenticated: (req, res, next) => next(),
expressSession: (req, res, next) => next(),
}));
// Mock database connections and initializations in index.js
jest.unstable_mockModule('../database/mongo.js', () => ({
dbConnect: jest.fn(),
}));
jest.unstable_mockModule('../database/nats.js', () => ({
natsServer: { connect: jest.fn() },
}));
jest.unstable_mockModule('../database/ceph.js', () => ({
initializeBuckets: jest.fn(),
uploadFile: jest.fn(),
downloadFile: jest.fn(),
deleteFile: jest.fn(),
fileExists: jest.fn(),
listFiles: jest.fn(),
getFileMetadata: jest.fn(),
getPresignedUrl: jest.fn(),
BUCKETS: { FILES: 'test-bucket' },
}));
// Mock the service handlers to avoid database calls
jest.unstable_mockModule('../services/management/users.js', () => ({
listUsersRouteHandler: jest.fn((req, res) => res.send([{ id: '1', name: 'Mock User' }])),
listUsersByPropertiesRouteHandler: jest.fn(),
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(),
}));
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

@ -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,166 +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),
getFieldsByRef: jest.fn(() => []),
getQueryToCacheKey: jest.fn(({ model, id }) => `${model}:${id}`),
modelHasRef: jest.fn(() => false),
newAuditLog: jest.fn(),
distributeNew: jest.fn(),
distributeUpdate: jest.fn(),
distributeChildUpdate: jest.fn(),
distributeChildDelete: jest.fn(),
distributeChildNew: jest.fn(),
distributeStats: jest.fn(),
}));
jest.unstable_mockModule('../redis.js', () => ({
redisServer: {
getKey: jest.fn(),
setKey: jest.fn(),
deleteKey: jest.fn(),
getKeysByPattern: jest.fn(() => []), // Return empty array to avoid iterable error
},
}));
jest.unstable_mockModule('../../services/misc/model.js', () => ({
getAllModels: jest.fn(() => []),
}));
jest.unstable_mockModule('../schemas/management/auditlog.schema.js', () => ({
auditLogModel: {
find: jest.fn(),
create: jest.fn(),
},
}));
// Mock fileModel specifically as it's imported by database.js
jest.unstable_mockModule('../schemas/management/file.schema.js', () => ({
fileModel: {
findById: jest.fn(),
},
}));
// Now import the database utilities
const { listObjects, getObject, newObject, editObject, deleteObject } = await import(
'../database.js'
);
describe('Database Utilities (CRUD)', () => {
let mockModel;
beforeEach(() => {
mockModel = {
modelName: 'TestModel',
find: jest.fn().mockReturnThis(),
findById: jest.fn().mockReturnThis(),
findByIdAndUpdate: jest.fn().mockReturnThis(),
findByIdAndDelete: jest.fn().mockReturnThis(),
create: jest.fn(),
sort: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
populate: jest.fn().mockReturnThis(),
select: jest.fn().mockReturnThis(),
lean: jest.fn().mockReturnThis(),
exec: jest.fn(),
};
jest.clearAllMocks();
});
describe('listObjects', () => {
it('should return a list of objects', async () => {
const mockData = [{ _id: '1', name: 'Test' }];
mockModel.lean.mockResolvedValue(mockData);
const result = await listObjects({ model: mockModel });
expect(mockModel.find).toHaveBeenCalled();
expect(result).toEqual(mockData);
});
it('should handle pagination', async () => {
await listObjects({ model: mockModel, page: 2, limit: 10 });
expect(mockModel.skip).toHaveBeenCalledWith(10);
expect(mockModel.limit).toHaveBeenCalledWith(10);
});
});
describe('getObject', () => {
it('should return a single object by ID', async () => {
const mockData = { _id: '123', name: 'Test' };
mockModel.lean.mockResolvedValue(mockData);
const result = await getObject({ model: mockModel, id: '123' });
expect(mockModel.findById).toHaveBeenCalledWith('123');
expect(result).toEqual(mockData);
});
it('should return 404 if object not found', async () => {
mockModel.lean.mockResolvedValue(null);
const result = await getObject({ model: mockModel, id: '123' });
expect(result).toEqual({ error: 'Object not found.', code: 404 });
});
});
describe('newObject', () => {
it('should create a new object', async () => {
const newData = { name: 'New' };
const createdData = { _id: '456', ...newData };
mockModel.create.mockResolvedValue({
toObject: () => createdData,
_id: '456',
});
const result = await newObject({ model: mockModel, newData });
expect(mockModel.create).toHaveBeenCalledWith(newData);
expect(result).toEqual(createdData);
});
});
describe('editObject', () => {
it('should update an existing object', async () => {
const id = '123';
const updateData = { name: 'Updated' };
const previousData = { _id: id, name: 'Old' };
mockModel.lean.mockResolvedValue(previousData);
const result = await editObject({ model: mockModel, id, updateData });
expect(mockModel.findByIdAndUpdate).toHaveBeenCalledWith(id, updateData);
expect(result).toEqual({ ...previousData, ...updateData });
});
});
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,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');
}

View File

@ -1,7 +1,6 @@
import config from '../config.js';
import { fileModel } from './schemas/management/file.schema.js';
import _ from 'lodash';
import log4js from 'log4js';
import {
deleteAuditLog,
distributeDelete,
@ -9,6 +8,9 @@ import {
modelHasRef,
getFieldsByRef,
getQueryToCacheKey,
} from '../utils.js';
import log4js from 'log4js';
import {
editAuditLog,
distributeUpdate,
newAuditLog,
@ -17,13 +19,10 @@ import {
distributeChildDelete,
distributeChildNew,
distributeStats,
editNotification,
deleteNotification,
} from '../utils.js';
import { getAllModels } from '../services/misc/model.js';
import { redisServer } from './redis.js';
import { auditLogModel } from './schemas/management/auditlog.schema.js';
import { convertObjectIdStringsInFilter } from './utils.js';
const logger = log4js.getLogger('Database');
logger.level = config.server.logLevel;
@ -350,14 +349,12 @@ export const listObjects = async ({
filter = {},
sort = '',
order = 'ascend',
pagination = true,
project, // optional: override default projection
}) => {
try {
logger.trace('Listing object:', {
model,
populate,
pagination,
page,
limit,
filter,
@ -366,7 +363,7 @@ export const listObjects = async ({
project,
});
// Calculate the skip value based on the page number and limit
const skip = pagination ? (page - 1) * limit : 0;
const skip = (page - 1) * limit;
// Fix: descend should be -1, ascend should be 1
const sortOrder = order === 'descend' ? -1 : 1;
@ -374,6 +371,10 @@ export const listObjects = async ({
sort = 'createdAt';
}
if (filter) {
console.log('filter', filter);
}
// Translate any key ending with ._id to remove the ._id suffix for Mongoose
Object.keys(filter).forEach((key) => {
if (key.endsWith('._id')) {
@ -388,7 +389,7 @@ export const listObjects = async ({
.find(filter)
.sort({ [sort]: sortOrder })
.skip(skip)
.limit(pagination ? Number(limit) : undefined);
.limit(Number(limit));
// Handle populate (array or single value)
if (populate) {
@ -476,6 +477,8 @@ function nestGroups(groups, props, filter, idx = 0) {
// Check if any group in this key matches the filter (by _id or name)
const matches = groupList.filter((group) => {
const { filterVals } = getKeyAndFilterVals(group._id[prop]);
console.log('filterVals', filterVals);
console.log('filterValue', filterValue);
return filterVals.some((val) => val?.toString() === filterValue);
});
@ -519,6 +522,7 @@ export const listObjectsByProperties = async ({
populate,
}) => {
try {
console.log('Props', properties);
const propertiesPresent = !(
!Array.isArray(properties) ||
properties.length === 0 ||
@ -571,12 +575,8 @@ export const listObjectsByProperties = async ({
}
}
logger.debug('Master filter:', masterFilter);
if (Object.keys(masterFilter).length > 0) {
const convertedFilter = convertObjectIdStringsInFilter(masterFilter);
logger.debug('Converted filter:', convertedFilter);
pipeline.push({ $match: convertedFilter });
if (masterFilter != {}) {
pipeline.push({ $match: { ...masterFilter } });
}
if (propertiesPresent) {
@ -594,17 +594,15 @@ export const listObjectsByProperties = async ({
// Run aggregation
const results = await model.aggregate(pipeline);
console.log('results', results);
return nestGroups(results, properties, filter);
} else {
// If no properties specified, just return all objects without grouping
// Ensure pipeline is not empty by adding a $match stage if needed
if (pipeline.length === 0 && Object.keys(masterFilter).length === 0) {
console.log('Adding empty match stage');
if (pipeline.length === 0) {
pipeline.push({ $match: {} });
}
console.log('Running pipeline:', pipeline);
const results = await model.aggregate(pipeline);
console.log('Results:', results);
return results;
}
} catch (error) {
@ -733,25 +731,8 @@ export const listObjectDependencies = async ({ model, id }) => {
}
};
export const checkStates = async ({ model, id, states }) => {
try {
const object = await getObject({ model, id, cached: true });
if (!object?.state?.type) {
logger.warn(`Object ${id} has no state type.`);
return false;
}
if (states.includes(object?.state?.type)) {
return true;
}
return false;
} catch (error) {
logger.error('checkStates error:', error);
return { error: error.message, code: 500 };
}
};
// Reusable function to edit an object by ID, with audit logging and distribution
export const editObject = async ({ model, id, updateData, user, populate, recalculate = true }) => {
export const editObject = async ({ model, id, updateData, user, populate }) => {
try {
// Determine parentType from model name
const parentType = model.modelName ? model.modelName : 'unknown';
@ -810,20 +791,6 @@ export const editObject = async ({ model, id, updateData, user, populate, recalc
parentType,
user
);
if (
parentType !== 'notification' &&
parentType !== 'auditLog' &&
parentType !== 'userNotifier'
) {
await editNotification(
previousExpandedObject,
{ ...previousExpandedObject, ...updateData },
id,
parentType,
user
);
}
// Distribute update
await distributeUpdate(updateData, id, parentType);
// Call childUpdate event for any child objects
@ -843,7 +810,7 @@ export const editObject = async ({ model, id, updateData, user, populate, recalc
populate,
});
if (model.recalculate && recalculate == true) {
if (model.recalculate) {
logger.debug(`Recalculating ${model.modelName}`);
await model.recalculate(updatedObject, user);
}
@ -861,33 +828,6 @@ export const editObject = async ({ model, id, updateData, user, populate, recalc
}
};
// Reusable function to edit multiple objects
export const editObjects = async ({ model, updates, user, populate, recalculate = true }) => {
try {
const results = [];
for (const update of updates) {
const id = update._id || update.id;
const updateData = { ...update };
delete updateData._id;
delete updateData.id;
const result = await editObject({
model,
id,
updateData,
user,
populate,
recalculate,
});
results.push(result);
}
return results;
} catch (error) {
logger.error('editObjects error:', error);
return { error: error.message, code: 500 };
}
};
// Reusable function to create a new object
export const newObject = async ({ model, newData, user = null }, distributeChanges = true) => {
try {
@ -900,7 +840,6 @@ export const newObject = async ({ model, newData, user = null }, distributeChang
const created = expandObjectIds(result.toObject());
await newAuditLog(newData, created._id, parentType, user);
if (distributeChanges == true) {
await distributeNew(created, parentType);
}
@ -933,28 +872,9 @@ export const newObject = async ({ model, newData, user = null }, distributeChang
};
// Reusable function to delete an object by ID, with audit logging and distribution
export const deleteObject = async (
{ model, id, user = null, checkUnused = false },
distributeChanges = true
) => {
export const deleteObject = async ({ model, id, user = null }, distributeChanges = true) => {
try {
const parentType = model.modelName ? model.modelName : 'unknown';
if (checkUnused) {
const dependencies = await listObjectDependencies({ model, id });
if (dependencies?.error) {
return { error: dependencies.error, code: dependencies.code || 500 };
}
if (dependencies?.length > 0) {
return {
error: 'Object is in use and cannot be deleted',
code: 409,
dependencies: dependencies.length,
dependencyDetails: dependencies,
};
}
}
// Delete the object
const result = await model.findByIdAndDelete(id);
@ -964,15 +884,7 @@ export const deleteObject = async (
const deleted = expandObjectIds(result.toObject());
// Audit log the deletion
await deleteAuditLog(deleted, id, parentType, user);
if (
parentType !== 'notification' &&
parentType !== 'auditLog' &&
parentType !== 'userNotifier'
) {
await deleteNotification(deleted, id, parentType, user);
}
await deleteAuditLog(deleted, id, parentType, user, 'delete');
if (distributeChanges == true) {
await distributeDelete(deleted, parentType);

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,189 +0,0 @@
import mongoose from 'mongoose';
import { generateId } from '../../utils.js';
const { Schema } = mongoose;
import { aggregateRollups, aggregateRollupsHistory, editObject } from '../../database.js';
const invoiceOrderItemSchema = new Schema(
{
orderItem: { type: Schema.Types.ObjectId, ref: 'orderItem', required: true },
taxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
invoiceAmountWithTax: { type: Number, required: true, default: 0 },
invoiceAmount: { type: Number, required: true, default: 0 },
invoiceQuantity: { type: Number, required: true, default: 0 },
},
{ timestamps: true }
);
const invoiceShipmentSchema = new Schema(
{
shipment: { type: Schema.Types.ObjectId, ref: 'shipment', required: true },
taxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
invoiceAmountWithTax: { type: Number, required: true, default: 0 },
invoiceAmount: { type: Number, required: true, default: 0 },
},
{ timestamps: true }
);
const invoiceSchema = new Schema(
{
_reference: { type: String, default: () => generateId()() },
totalAmount: { type: Number, required: true, default: 0 },
totalAmountWithTax: { type: Number, required: true, default: 0 },
shippingAmount: { type: Number, required: true, default: 0 },
shippingAmountWithTax: { type: Number, required: true, default: 0 },
grandTotalAmount: { type: Number, required: true, default: 0 },
totalTaxAmount: { type: Number, required: true, default: 0 },
from: { type: Schema.Types.ObjectId, refPath: 'fromType', required: false },
fromType: { type: String, required: false },
to: { type: Schema.Types.ObjectId, refPath: 'toType', required: false },
toType: { type: String, required: false },
state: {
type: { type: String, required: true, default: 'draft' },
},
orderType: { type: String, required: true },
order: { type: Schema.Types.ObjectId, refPath: 'orderType', required: true },
issuedAt: { type: Date, required: false },
dueAt: { type: Date, required: false },
postedAt: { type: Date, required: false },
acknowledgedAt: { type: Date, required: false },
paidAt: { type: Date, required: false },
cancelledAt: { type: Date, required: false },
invoiceOrderItems: [invoiceOrderItemSchema],
invoiceShipments: [invoiceShipmentSchema],
},
{ timestamps: true }
);
const rollupConfigs = [
{
name: 'draft',
filter: { 'state.type': 'draft' },
rollups: [
{ name: 'draftCount', property: 'state.type', operation: 'count' },
{ name: 'draftGrandTotalAmount', property: 'grandTotalAmount', operation: 'sum' },
],
},
{
name: 'sent',
filter: { 'state.type': 'sent' },
rollups: [
{ name: 'sentCount', property: 'state.type', operation: 'count' },
{ name: 'sentGrandTotalAmount', property: 'grandTotalAmount', operation: 'sum' },
],
},
{
name: 'acknowledged',
filter: { 'state.type': 'acknowledged' },
rollups: [
{ name: 'acknowledgedCount', property: 'state.type', operation: 'count' },
{ name: 'acknowledgedGrandTotalAmount', property: 'grandTotalAmount', operation: 'sum' },
],
},
{
name: 'partiallyPaid',
filter: { 'state.type': 'partiallyPaid' },
rollups: [
{ name: 'partiallyPaidCount', property: 'state.type', operation: 'count' },
{ name: 'partiallyPaidGrandTotalAmount', property: 'grandTotalAmount', operation: 'sum' },
],
},
{
name: 'paid',
filter: { 'state.type': 'paid' },
rollups: [{ name: 'paidCount', property: 'state.type', operation: 'count' }],
},
{
name: 'overdue',
filter: { 'state.type': 'overdue' },
rollups: [{ name: 'overdueCount', property: 'state.type', operation: 'count' }],
},
{
name: 'cancelled',
filter: { 'state.type': 'cancelled' },
rollups: [{ name: 'cancelledCount', property: 'state.type', operation: 'count' }],
},
];
invoiceSchema.statics.stats = async function () {
const results = await aggregateRollups({
model: this,
rollupConfigs: rollupConfigs,
});
// Transform the results to match the expected format
return results;
};
invoiceSchema.statics.history = async function (from, to) {
const results = await aggregateRollupsHistory({
model: this,
startDate: from,
endDate: to,
rollupConfigs: rollupConfigs,
});
// Return time-series data array
return results;
};
invoiceSchema.statics.recalculate = async function (invoice, user) {
const invoiceId = invoice._id || invoice;
if (!invoiceId) {
return;
}
// Calculate totals from invoiceOrderItems
let totalAmount = 0;
for (const item of invoice.invoiceOrderItems || []) {
totalAmount += Number.parseFloat(item.invoiceAmount) || 0;
}
let totalAmountWithTax = 0;
for (const item of invoice.invoiceOrderItems || []) {
totalAmountWithTax += Number.parseFloat(item.invoiceAmountWithTax) || 0;
}
// Calculate shipping totals from invoiceShipments
let shippingAmount = 0;
for (const item of invoice.invoiceShipments || []) {
shippingAmount += Number.parseFloat(item.invoiceAmount) || 0;
}
let shippingAmountWithTax = 0;
for (const item of invoice.invoiceShipments || []) {
shippingAmountWithTax += Number.parseFloat(item.invoiceAmountWithTax) || 0;
}
// Calculate grand total and tax amount
const grandTotalAmount = parseFloat(totalAmountWithTax) + parseFloat(shippingAmountWithTax);
const totalTaxAmount =
parseFloat(totalAmountWithTax) -
parseFloat(totalAmount) +
(parseFloat(shippingAmountWithTax) - parseFloat(shippingAmount));
const updateData = {
totalAmount: parseFloat(totalAmount).toFixed(2),
totalAmountWithTax: parseFloat(totalAmountWithTax).toFixed(2),
shippingAmount: parseFloat(shippingAmount).toFixed(2),
shippingAmountWithTax: parseFloat(shippingAmountWithTax).toFixed(2),
grandTotalAmount: parseFloat(grandTotalAmount).toFixed(2),
totalTaxAmount: parseFloat(totalTaxAmount).toFixed(2),
};
await editObject({
model: this,
id: invoiceId,
updateData,
user,
recalculate: false,
});
};
// Add virtual id getter
invoiceSchema.virtual('id').get(function () {
return this._id;
});
// Configure JSON serialization to include virtuals
invoiceSchema.set('toJSON', { virtuals: true });
// Create and export the model
export const invoiceModel = mongoose.model('invoice', invoiceSchema);

View File

@ -1,123 +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' },
},
paymentDate: { type: Date, required: false },
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 }
);
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,26 +1,7 @@
import mongoose from 'mongoose';
import { generateId } from '../../utils.js';
const { Schema } = mongoose;
import { aggregateRollups, aggregateRollupsHistory, editObject } from '../../database.js';
import { stockEventModel } from './stockevent.schema.js';
const getStockEventTotal = async (stock, parentType) => {
const stockId = stock?._id;
if (!stockId) return null;
const parentId =
stockId instanceof mongoose.Types.ObjectId ? stockId : new mongoose.Types.ObjectId(stockId);
const [result] = await stockEventModel.aggregate([
{ $match: { parent: parentId, parentType } },
{ $group: { _id: null, total: { $sum: '$value' }, count: { $sum: 1 } } },
]);
return {
total: result?.total ?? 0,
count: result?.count ?? 0,
};
};
import { aggregateRollups, aggregateRollupsHistory } from '../../database.js';
// Define the main filamentStock schema
const filamentStockSchema = new Schema(
@ -39,27 +20,10 @@ const filamentStockSchema = new Schema(
gross: { type: Number, required: true },
},
filament: { type: mongoose.Schema.Types.ObjectId, ref: 'filament', required: true },
filamentSku: { type: mongoose.Schema.Types.ObjectId, ref: 'filamentSku', required: true },
stockLocation: {
type: mongoose.Schema.Types.ObjectId,
ref: 'stockLocation',
required: false,
},
},
{ timestamps: true }
);
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',
@ -89,33 +53,6 @@ filamentStockSchema.statics.history = async function (from, to) {
return results;
};
filamentStockSchema.statics.recalculate = async function (filamentStock, user) {
const events = await getStockEventTotal(filamentStock, this.modelName);
if (!events?.count) return;
const net = events.total;
const startingNet = filamentStock.startingWeight?.net ?? 0;
const startingGross = filamentStock.startingWeight?.gross ?? 0;
const gross = startingNet > 0 ? (startingGross * net) / startingNet : net;
console.log('Recalculating filament stock');
console.log('events', events);
console.log('filamentStock', filamentStock);
await editObject({
model: this,
id: filamentStock._id,
updateData: {
currentWeight: {
net,
gross,
},
},
user,
recalculate: false,
});
};
// Add virtual id getter
filamentStockSchema.virtual('id').get(function () {
return this._id;

View File

@ -1,112 +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';
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 },
orderedAt: { type: Date, required: false },
receivedAt: { type: Date, required: false },
},
{ timestamps: true }
);
const rollupConfigs = [
{
name: 'shipped',
filter: { 'state.type': 'shipped' },
rollups: [{ name: 'shipped', property: 'state.type', operation: 'count' }],
},
{
name: 'received',
filter: { 'state.type': 'received' },
rollups: [{ name: 'received', property: 'state.type', operation: 'count' }],
},
];
orderItemSchema.statics.stats = async function () {
const results = await aggregateRollups({
model: this,
baseFilter: {},
rollupConfigs: rollupConfigs,
});
// Transform the results to match the expected format
return results;
};
orderItemSchema.statics.history = async function (from, to) {
const results = await aggregateRollupsHistory({
model: this,
startDate: from,
endDate: to,
rollupConfigs: rollupConfigs,
});
// Return time-series data array
return results;
};
orderItemSchema.statics.recalculate = async function (orderItem, user) {
if (orderItem.orderType !== 'purchaseOrder' && orderItem.orderType !== 'salesOrder') {
// Only purchase orders are supported for now
if (orderItem.orderType !== 'purchaseOrder') {
return;
}
@ -115,85 +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;
}
}
}
}
}
}
let taxRate = orderItem.taxRate;
if (orderItem.taxRate?._id && Object.keys(orderItem.taxRate).length === 1) {
taxRate = await getObject({
model: taxRateModel,
id: orderItem.taxRate._id,
cached: true,
});
}
const orderTotalAmount = effectiveItemAmount * orderItem.quantity;
const orderTotalAmountWithTax = orderTotalAmount * (1 + (taxRate?.rate || 0) / 100);
const orderItemUpdateData = {
totalAmount: orderTotalAmount,
totalAmountWithTax: orderTotalAmountWithTax,
invoicedAmountRemaining: orderTotalAmount - orderItem.invoicedAmount,
invoicedAmountWithTaxRemaining: orderTotalAmountWithTax - orderItem.invoicedAmountWithTax,
invoicedQuantityRemaining: orderItem.quantity - orderItem.invoicedQuantity,
};
if (effectiveItemAmount !== orderItem.itemAmount) {
orderItemUpdateData.itemAmount = effectiveItemAmount;
orderItem.itemAmount = effectiveItemAmount;
}
await editObject({
model: this,
id: orderItem._id,
updateData: orderItemUpdateData,
user,
recalculate: false,
});
const rollupResults = await aggregateRollups({
model: this,
baseFilter: {
@ -212,11 +51,6 @@ orderItemSchema.statics.recalculate = async function (orderItem, user) {
},
],
},
{
name: 'overallCount',
rollups: [{ name: 'overallCount', property: '_id', operation: 'count' }],
},
...rollupConfigs,
],
});
@ -224,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,26 +1,7 @@
import mongoose from 'mongoose';
import { generateId } from '../../utils.js';
const { Schema } = mongoose;
import { aggregateRollups, aggregateRollupsHistory, editObject } from '../../database.js';
import { stockEventModel } from './stockevent.schema.js';
const getStockEventTotal = async (stock, parentType) => {
const stockId = stock?._id;
if (!stockId) return null;
const parentId =
stockId instanceof mongoose.Types.ObjectId ? stockId : new mongoose.Types.ObjectId(stockId);
const [result] = await stockEventModel.aggregate([
{ $match: { parent: parentId, parentType } },
{ $group: { _id: null, total: { $sum: '$value' }, count: { $sum: 1 } } },
]);
return {
total: result?.total ?? 0,
count: result?.count ?? 0,
};
};
import { aggregateRollups, aggregateRollupsHistory } from '../../database.js';
// Define the main partStock schema
const partStockSchema = new Schema(
@ -30,12 +11,7 @@ const partStockSchema = new Schema(
type: { type: String, required: true },
progress: { type: Number, required: false },
},
partSku: { type: mongoose.Schema.Types.ObjectId, ref: 'partSku', required: true },
stockLocation: {
type: mongoose.Schema.Types.ObjectId,
ref: 'stockLocation',
required: false,
},
part: { type: mongoose.Schema.Types.ObjectId, ref: 'part', required: true },
currentQuantity: { type: Number, required: true },
sourceType: { type: String, required: true },
source: { type: Schema.Types.ObjectId, refPath: 'sourceType', required: true },
@ -72,21 +48,6 @@ partStockSchema.statics.history = async function (from, to) {
return results;
};
partStockSchema.statics.recalculate = async function (partStock, user) {
const events = await getStockEventTotal(partStock, this.modelName);
if (!events?.count) return;
await editObject({
model: this,
id: partStock._id,
updateData: {
currentQuantity: events.total,
},
user,
recalculate: false,
});
};
// Add virtual id getter
partStockSchema.virtual('id').get(function () {
return this._id;

View File

@ -1,114 +0,0 @@
import mongoose from 'mongoose';
import { generateId } from '../../utils.js';
const { Schema } = mongoose;
import { aggregateRollups, aggregateRollupsHistory, editObject } from '../../database.js';
import { stockEventModel } from './stockevent.schema.js';
const getStockEventTotal = async (stock, parentType) => {
const stockId = stock?._id;
if (!stockId) return null;
const parentId =
stockId instanceof mongoose.Types.ObjectId ? stockId : new mongoose.Types.ObjectId(stockId);
const [result] = await stockEventModel.aggregate([
{ $match: { parent: parentId, parentType } },
{ $group: { _id: null, total: { $sum: '$value' }, count: { $sum: 1 } } },
]);
return {
total: result?.total ?? 0,
count: result?.count ?? 0,
};
};
const partStockUsageSchema = new Schema({
partStock: { type: Schema.Types.ObjectId, ref: 'partStock', required: false },
partSku: { type: Schema.Types.ObjectId, ref: 'partSku', required: true },
quantity: { type: Number, required: true },
});
// Define the main productStock schema - tracks assembled products consisting of part stocks
const productStockSchema = new Schema(
{
_reference: { type: String, default: () => generateId()() },
state: {
type: { type: String, required: true, default: 'draft' },
progress: { type: Number, required: false },
},
postedAt: { type: Date, required: false },
productSku: { type: mongoose.Schema.Types.ObjectId, ref: 'productSku', required: true },
stockLocation: {
type: mongoose.Schema.Types.ObjectId,
ref: 'stockLocation',
required: false,
},
currentQuantity: { type: Number, required: true },
partStocks: [partStockUsageSchema],
},
{ timestamps: true }
);
const rollupConfigs = [
{
name: 'totalCurrentQuantity',
filter: {},
rollups: [{ name: 'totalCurrentQuantity', property: 'currentQuantity', operation: 'sum' }],
},
{
name: 'draft',
filter: { 'state.type': 'draft' },
rollups: [{ name: 'draft', property: 'state.type', operation: 'count' }],
},
{
name: 'posted',
filter: { 'state.type': 'posted' },
rollups: [{ name: 'posted', property: 'state.type', operation: 'count' }],
},
];
productStockSchema.statics.stats = async function () {
const results = await aggregateRollups({
model: this,
rollupConfigs: rollupConfigs,
});
return results;
};
productStockSchema.statics.history = async function (from, to) {
const results = await aggregateRollupsHistory({
model: this,
startDate: from,
endDate: to,
rollupConfigs: rollupConfigs,
});
return results;
};
productStockSchema.statics.recalculate = async function (productStock, user) {
const events = await getStockEventTotal(productStock, this.modelName);
if (!events?.count) return;
await editObject({
model: this,
id: productStock._id,
updateData: {
currentQuantity: events.total,
},
user,
recalculate: false,
});
};
// 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,100 +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 }
);
const rollupConfigs = [
{
name: 'draft',
filter: { 'state.type': 'draft' },
rollups: [{ name: 'draft', property: 'state.type', operation: 'count' }],
},
{
name: 'sent',
filter: { 'state.type': 'sent' },
rollups: [{ name: 'sent', property: 'state.type', operation: 'count' }],
},
{
name: 'acknowledged',
filter: { 'state.type': 'acknowledged' },
rollups: [{ name: 'acknowledged', property: 'state.type', operation: 'count' }],
},
{
name: 'partiallyShipped',
filter: { 'state.type': 'partiallyShipped' },
rollups: [{ name: 'partiallyShipped', property: 'state.type', operation: 'count' }],
},
{
name: 'shipped',
filter: { 'state.type': 'shipped' },
rollups: [{ name: 'shipped', property: 'state.type', operation: 'count' }],
},
{
name: 'partiallyReceived',
filter: { 'state.type': 'partiallyReceived' },
rollups: [{ name: 'partiallyReceived', property: 'state.type', operation: 'count' }],
},
{
name: 'received',
filter: { 'state.type': 'received' },
rollups: [{ name: 'received', property: 'state.type', operation: 'count' }],
},
{
name: 'cancelled',
filter: { 'state.type': 'cancelled' },
rollups: [{ name: 'cancelled', property: 'state.type', operation: 'count' }],
},
{
name: 'completed',
filter: { 'state.type': 'completed' },
rollups: [{ name: 'completed', property: 'state.type', operation: 'count' }],
},
];
purchaseOrderSchema.statics.stats = async function () {
const results = await aggregateRollups({
model: this,
rollupConfigs: rollupConfigs,
});
// Transform the results to match the expected format
return results;
};
purchaseOrderSchema.statics.history = async function (from, to) {
const results = await aggregateRollupsHistory({
model: this,
startDate: from,
endDate: to,
rollupConfigs: rollupConfigs,
});
// Return time-series data array
return results;
};
// Add virtual id getter
purchaseOrderSchema.virtual('id').get(function () {
return this._id;

View File

@ -1,117 +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, editObject, getObject } from '../../database.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 },
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.statics.recalculate = async function (shipment, user) {
if (shipment.orderType !== 'purchaseOrder' && shipment.orderType !== 'salesOrder') {
return;
}
const orderId = shipment.order?._id || shipment.order;
if (!orderId) {
return;
}
var taxRate = shipment.taxRate;
if (shipment.taxRate?._id && Object.keys(shipment.taxRate).length == 1) {
taxRate = await getObject({
model: taxRateModel,
id: shipment.taxRate._id,
cached: true,
});
}
const amountWithTax = parseFloat(
(shipment.amount || 0) * (1 + (taxRate?.rate || 0) / 100)
).toFixed(2);
await editObject({
model: shipmentModel,
id: shipment._id,
updateData: {
amountWithTax: amountWithTax,
invoicedAmountRemaining: shipment.amount - (shipment.invoicedAmount || 0),
invoicedAmountWithTaxRemaining: amountWithTax - (shipment.invoicedAmountWithTax || 0),
},
user,
recalculate: false,
});
const rollupResults = await aggregateRollups({
model: this,
baseFilter: {
order: new mongoose.Types.ObjectId(orderId),
orderType: shipment.orderType,
},
rollupConfigs: [
{
name: 'shipmentTotals',
rollups: [
{ name: 'amount', property: 'amount', operation: 'sum' },
{ name: 'amountWithTax', property: 'amountWithTax', operation: 'sum' },
],
},
],
});
const totals = rollupResults.shipmentTotals || {};
const totalShippingAmount = totals.amount.sum?.toFixed(2) || 0;
const totalShippingAmountWithTax = totals.amountWithTax.sum?.toFixed(2) || 0;
const orderModel = shipment.orderType === 'purchaseOrder' ? purchaseOrderModel : salesOrderModel;
const order = await getObject({
model: orderModel,
id: orderId,
cached: true,
});
const grandTotalAmount =
parseFloat(order.totalAmountWithTax || 0) + parseFloat(totalShippingAmountWithTax || 0);
await editObject({
model: orderModel,
id: orderId,
updateData: {
shippingAmount: parseFloat(totalShippingAmount).toFixed(2),
shippingAmountWithTax: parseFloat(totalShippingAmountWithTax).toFixed(2),
grandTotalAmount: parseFloat(grandTotalAmount).toFixed(2),
},
user,
recalculate: false,
});
};
// Add virtual id getter
shipmentSchema.virtual('id').get(function () {
return this._id;

View File

@ -25,7 +25,7 @@ const stockEventSchema = new Schema(
ownerType: {
type: String,
required: true,
enum: ['user', 'subJob', 'stockAudit', 'stockTransfer'],
enum: ['user', 'subJob', 'stockAudit'],
},
timestamp: { type: Date, default: Date.now },
},

View File

@ -1,39 +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.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,72 +0,0 @@
import mongoose from 'mongoose';
import { generateId } from '../../utils.js';
const { Schema } = mongoose;
const stockTransferLineSchema = new Schema(
{
fromStockType: {
type: String,
required: true,
enum: ['filamentStock', 'partStock', 'productStock'],
},
fromStock: {
type: Schema.Types.ObjectId,
refPath: 'lines.fromStockType',
required: true,
},
quantity: { type: Number, required: true },
toStockLocation: {
type: Schema.Types.ObjectId,
ref: 'stockLocation',
required: true,
},
toStockType: {
type: String,
required: false,
enum: ['filamentStock', 'partStock', 'productStock'],
},
toStock: {
type: Schema.Types.ObjectId,
refPath: 'lines.toStockType',
required: false,
},
},
{ _id: true }
);
const stockTransferSchema = new Schema(
{
_reference: { type: String, default: () => generateId()() },
name: { type: String, required: true },
state: {
type: { type: String, required: true, default: 'draft' },
progress: { type: Number, required: false },
},
postedAt: { type: Date, required: false },
lines: { type: [stockTransferLineSchema], default: [] },
},
{ timestamps: true }
);
stockTransferSchema.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,22 +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.virtual('id').get(function () {
return this._id;
});
appPasswordSchema.set('toJSON', { virtuals: true });
export const appPasswordModel = mongoose.model('appPassword', appPasswordSchema);

View File

@ -2,21 +2,24 @@ import mongoose from 'mongoose';
import { generateId } from '../../utils.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 },
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.virtual('id').get(function () {
return this._id;
@ -24,12 +27,4 @@ filamentSchema.virtual('id').get(function () {
filamentSchema.set('toJSON', { virtuals: true });
filamentSchema.statics.recalculate = async function (filament, user) {
const filamentSkuModel = mongoose.model('filamentSku');
const skus = await filamentSkuModel.find({ filament: filament._id }).select('_id').lean();
for (const sku of skus) {
await filamentSkuModel.recalculate(sku, user);
}
};
export const filamentModel = mongoose.model('filament', filamentSchema);

View File

@ -1,48 +0,0 @@
import mongoose from 'mongoose';
import { generateId } from '../../utils.js';
const { Schema } = mongoose;
// Define the main filament SKU schema - color and cost live at SKU level
const filamentSkuSchema = new Schema(
{
_reference: { type: String, default: () => generateId()() },
barcode: { type: String, required: false },
filament: { type: Schema.Types.ObjectId, ref: 'filament', required: true },
name: { type: String, required: true },
description: { type: String, required: false },
color: { type: String, required: true },
cost: { type: Number, required: false },
overrideCost: { type: Boolean, default: false },
costTaxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
costWithTax: { type: Number, required: false },
},
{ timestamps: true }
);
// Add virtual id getter
filamentSkuSchema.virtual('id').get(function () {
return this._id;
});
// Configure JSON serialization to include virtuals
filamentSkuSchema.set('toJSON', { virtuals: true });
filamentSkuSchema.statics.recalculate = async function (filamentSku, user) {
const orderItemModel = mongoose.model('orderItem');
const skuId = filamentSku._id;
const draftOrderItems = await orderItemModel
.find({
'state.type': 'draft',
itemType: 'filament',
sku: skuId,
})
.populate('order')
.lean();
for (const orderItem of draftOrderItems) {
await orderItemModel.recalculate(orderItem, user);
}
};
// Create and export the model
export const filamentSkuModel = mongoose.model('filamentSku', filamentSkuSchema);

View File

@ -56,8 +56,6 @@ const hostSchema = new mongoose.Schema(
connectedAt: { required: false, type: Date },
authCode: { type: { required: false, type: String } },
deviceInfo: { deviceInfoSchema },
otp: { type: { required: false, type: String } },
otpExpiresAt: { required: false, type: Date },
files: [{ type: mongoose.Schema.Types.ObjectId, ref: 'file' }],
},
{ timestamps: true }

View File

@ -1,15 +1,13 @@
import mongoose from 'mongoose';
import { generateId } from '../../utils.js';
const materialSchema = new mongoose.Schema(
{
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 }],
},
{ timestamps: true }
);
});
materialSchema.virtual('id').get(function () {
return this._id;

View File

@ -2,21 +2,22 @@ import mongoose from 'mongoose';
import { generateId } from '../../utils.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 }
);
@ -29,13 +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 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,54 +0,0 @@
import mongoose from 'mongoose';
import { generateId } from '../../utils.js';
const { Schema } = mongoose;
// Define the main part SKU schema - pricing lives at SKU level
const partSkuSchema = new Schema(
{
_reference: { type: String, default: () => generateId()() },
barcode: { type: String, required: false },
part: { type: Schema.Types.ObjectId, ref: 'part', required: true },
name: { type: String, required: true },
description: { type: String, required: false },
priceMode: { type: String, default: 'margin' },
price: { type: Number, required: false },
cost: { type: Number, required: false },
overrideCost: { type: Boolean, default: false },
overridePrice: { type: Boolean, default: false },
margin: { type: Number, required: false },
amount: { type: Number, required: false },
priceTaxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
costTaxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
priceWithTax: { type: Number, required: false },
costWithTax: { type: Number, required: false },
},
{ timestamps: true }
);
// Add virtual id getter
partSkuSchema.virtual('id').get(function () {
return this._id;
});
// Configure JSON serialization to include virtuals
partSkuSchema.set('toJSON', { virtuals: true });
partSkuSchema.statics.recalculate = async function (partSku, user) {
const orderItemModel = mongoose.model('orderItem');
const skuId = partSku._id;
const draftOrderItems = await orderItemModel
.find({
'state.type': 'draft',
itemType: 'part',
sku: skuId,
})
.populate('order')
.lean();
for (const orderItem of draftOrderItems) {
await orderItemModel.recalculate(orderItem, user);
}
};
// Create and export the model
export const partSkuModel = mongoose.model('partSku', partSkuSchema);

View File

@ -2,24 +2,25 @@ import mongoose from 'mongoose';
import { generateId } from '../../utils.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 }
);
@ -31,23 +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 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,18 +0,0 @@
import mongoose from 'mongoose';
import { generateId } from '../../utils.js';
const productCategorySchema = new mongoose.Schema(
{
_reference: { type: String, default: () => generateId()() },
name: { required: true, type: String },
},
{ timestamps: true }
);
productCategorySchema.virtual('id').get(function () {
return this._id;
});
productCategorySchema.set('toJSON', { virtuals: true });
export const productCategoryModel = mongoose.model('productCategory', productCategorySchema);

View File

@ -1,61 +0,0 @@
import mongoose from 'mongoose';
import { generateId } from '../../utils.js';
const { Schema } = mongoose;
const partSkuUsageSchema = new Schema({
part: { type: Schema.Types.ObjectId, ref: 'part', required: true },
partSku: { type: Schema.Types.ObjectId, ref: 'partSku', required: true },
quantity: { type: Number, required: true },
});
// Define the main product SKU schema
const productSkuSchema = new Schema(
{
_reference: { type: String, default: () => generateId()() },
barcode: { type: String, required: false },
product: { type: Schema.Types.ObjectId, ref: 'product', required: true },
name: { type: String, required: true },
description: { type: String, required: false },
priceMode: { type: String, default: 'margin' },
price: { type: Number, required: false },
cost: { type: Number, required: false },
overrideCost: { type: Boolean, default: false },
overridePrice: { type: Boolean, default: false },
margin: { type: Number, required: false },
amount: { type: Number, required: false },
parts: [partSkuUsageSchema],
priceTaxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
costTaxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
priceWithTax: { type: Number, required: false },
costWithTax: { type: Number, required: false },
},
{ timestamps: true }
);
// Add virtual id getter
productSkuSchema.virtual('id').get(function () {
return this._id;
});
// Configure JSON serialization to include virtuals
productSkuSchema.set('toJSON', { virtuals: true });
productSkuSchema.statics.recalculate = async function (productSku, user) {
const orderItemModel = mongoose.model('orderItem');
const skuId = productSku._id;
const draftOrderItems = await orderItemModel
.find({
'state.type': 'draft',
itemType: 'product',
sku: skuId,
})
.populate('order')
.lean();
for (const orderItem of draftOrderItems) {
await orderItemModel.recalculate(orderItem, user);
}
};
// Create and export the model
export const productSkuModel = mongoose.model('productSku', productSkuSchema);

View File

@ -10,7 +10,6 @@ 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 },
},
{ timestamps: true }
);

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,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

@ -2,31 +2,20 @@ import { jobModel } from './production/job.schema.js';
import { subJobModel } from './production/subjob.schema.js';
import { printerModel } from './production/printer.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 { 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 { 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 { documentSizeModel } from './management/documentsize.schema.js';
import { documentTemplateModel } from './management/documenttemplate.schema.js';
import { hostModel } from './management/host.schema.js';
@ -35,340 +24,78 @@ 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 { paymentModel } from './finance/payment.schema.js';
import { taxRateModel } from './management/taxrates.schema.js';
import { taxRecordModel } from './management/taxrecord.schema.js';
// Map prefixes to models and id fields
export const models = {
PRN: {
model: printerModel,
idField: '_id',
type: 'printer',
referenceField: '_reference',
label: 'Printer',
},
FIL: {
model: filamentModel,
idField: '_id',
type: 'filament',
referenceField: '_reference',
label: 'Filament',
},
FSU: {
model: filamentSkuModel,
idField: '_id',
type: 'filamentSku',
referenceField: '_reference',
label: 'Filament SKU',
},
GCF: {
model: gcodeFileModel,
idField: '_id',
type: 'gcodeFile',
referenceField: '_reference',
label: 'G-Code File',
},
JOB: { model: jobModel, idField: '_id', type: 'job', referenceField: '_reference', label: 'Job' },
PRT: {
model: partModel,
idField: '_id',
type: 'part',
referenceField: '_reference',
label: 'Part',
},
PSU: {
model: partSkuModel,
idField: '_id',
type: 'partSku',
referenceField: '_reference',
label: 'Part SKU',
},
PRD: {
model: productModel,
idField: '_id',
type: 'product',
referenceField: '_reference',
label: 'Product',
},
PCG: {
model: productCategoryModel,
idField: '_id',
type: 'productCategory',
referenceField: '_reference',
label: 'Product Category',
},
SKU: {
model: productSkuModel,
idField: '_id',
type: 'productSku',
referenceField: '_reference',
label: 'Product SKU',
},
VEN: {
model: vendorModel,
idField: '_id',
type: 'vendor',
referenceField: '_reference',
label: 'Vendor',
},
MAT: {
model: materialModel,
idField: '_id',
type: 'material',
referenceField: '_reference',
label: 'Material',
},
SJB: {
model: subJobModel,
idField: '_id',
type: 'subJob',
referenceField: '_reference',
label: 'Sub Job',
},
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',
label: 'Filament Stock',
},
SEV: {
model: stockEventModel,
idField: '_id',
type: 'stockEvent',
referenceField: '_reference',
label: 'Stock Event',
},
SAU: {
model: stockAuditModel,
idField: '_id',
type: 'stockAudit',
referenceField: '_reference',
label: 'Stock Audit',
},
PTS: {
model: partStockModel,
idField: '_id',
type: 'partStock',
referenceField: '_reference',
label: 'Part Stock',
},
PDS: {
model: productStockModel,
idField: '_id',
type: 'productStock',
referenceField: '_reference',
label: 'Product Stock',
},
SLN: {
model: stockLocationModel,
idField: '_id',
type: 'stockLocation',
referenceField: '_reference',
label: 'Stock Location',
},
STT: {
model: stockTransferModel,
idField: '_id',
type: 'stockTransfer',
referenceField: '_reference',
label: 'Stock Transfer',
},
ADL: {
model: auditLogModel,
idField: '_id',
type: 'auditLog',
referenceField: '_reference',
label: 'Audit Log',
},
USR: {
model: userModel,
idField: '_id',
type: 'user',
referenceField: '_reference',
label: 'User',
},
APP: {
model: appPasswordModel,
idField: '_id',
type: 'appPassword',
referenceField: '_reference',
label: 'App Password',
},
NTY: {
model: noteTypeModel,
idField: '_id',
type: 'noteType',
referenceField: '_reference',
label: 'Note Type',
},
NTE: {
model: noteModel,
idField: '_id',
type: 'note',
referenceField: '_reference',
label: 'Note',
},
NTF: {
model: notificationModel,
idField: '_id',
type: 'notification',
label: 'Notification',
referenceField: '_reference',
},
ONF: {
model: userNotifierModel,
idField: '_id',
type: 'userNotifier',
label: 'User Notifier',
referenceField: '_reference',
},
SEV: { model: stockEventModel, idField: '_id', type: 'stockEvent', referenceField: '_reference' },
SAU: { model: stockAuditModel, idField: '_id', type: 'stockAudit', referenceField: '_reference' },
PTS: { model: partStockModel, idField: '_id', type: 'partStock', referenceField: '_reference' },
PDS: { model: null, idField: '_id', type: 'productStock', referenceField: '_reference' }, // No productStockModel found
ADL: { model: auditLogModel, idField: '_id', type: 'auditLog', referenceField: '_reference' },
USR: { model: userModel, idField: '_id', type: 'user', referenceField: '_reference' },
NTY: { model: noteTypeModel, idField: '_id', type: 'noteType', referenceField: '_reference' },
NTE: { model: noteModel, idField: '_id', type: 'note', referenceField: '_reference' },
DSZ: {
model: documentSizeModel,
idField: '_id',
type: 'documentSize',
label: 'Document Size',
referenceField: '_reference',
},
DTP: {
model: documentTemplateModel,
idField: '_id',
type: 'documentTemplate',
label: 'Document Template',
referenceField: '_reference',
},
DPR: {
model: documentPrinterModel,
idField: '_id',
type: 'documentPrinter',
label: 'Document Printer',
referenceField: '_reference',
},
DJB: {
model: documentJobModel,
idField: '_id',
type: 'documentJob',
label: 'Document Job',
referenceField: '_reference',
},
HST: {
model: hostModel,
idField: '_id',
type: 'host',
referenceField: '_reference',
label: 'Host',
},
FLE: {
model: fileModel,
idField: '_id',
type: 'file',
referenceField: '_reference',
label: 'File',
},
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',
label: 'Purchase Order',
referenceField: '_reference',
},
ODI: {
model: orderItemModel,
idField: '_id',
type: 'orderItem',
label: 'Order Item',
referenceField: '_reference',
},
COS: {
model: courierServiceModel,
idField: '_id',
type: 'courierService',
label: 'Courier Service',
referenceField: '_reference',
},
COR: {
model: courierModel,
idField: '_id',
type: 'courier',
label: 'Courier',
referenceField: '_reference',
},
TXR: {
model: taxRateModel,
idField: '_id',
type: 'taxRate',
label: 'Tax Rate',
referenceField: '_reference',
},
TXD: {
model: taxRecordModel,
idField: '_id',
type: 'taxRecord',
label: 'Tax Record',
referenceField: '_reference',
},
SHP: {
model: shipmentModel,
idField: '_id',
type: 'shipment',
label: 'Shipment',
referenceField: '_reference',
},
INV: {
model: invoiceModel,
idField: '_id',
type: 'invoice',
label: 'Invoice',
referenceField: '_reference',
},
CLI: {
model: clientModel,
idField: '_id',
type: 'client',
label: 'Client',
referenceField: '_reference',
},
SOR: {
model: salesOrderModel,
idField: '_id',
type: 'salesOrder',
label: 'Sales Order',
referenceField: '_reference',
},
MKT: {
model: marketplaceModel,
idField: '_id',
type: 'marketplace',
label: 'Marketplace',
referenceField: '_reference',
},
LST: {
model: listingModel,
idField: '_id',
type: 'listing',
label: 'Listing',
referenceField: '_reference',
},
LVR: {
model: listingVarientModel,
idField: '_id',
type: 'listingVarient',
label: 'Listing Varient',
referenceField: '_reference',
},
PAY: {
model: paymentModel,
idField: '_id',
type: 'payment',
label: 'Payment',
referenceField: '_reference',
},
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

@ -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,13 +21,6 @@ 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;
}
});
gcodeFileSchema.index({ name: 'text', brand: 'text' });
gcodeFileSchema.virtual('id').get(function () {

View File

@ -92,6 +92,8 @@ printerSchema.statics.stats = async function () {
rollupConfigs: rollupConfigs,
});
console.log(results);
// Transform the results to match the expected format
return results;
};

View File

@ -1,35 +0,0 @@
import mongoose from 'mongoose';
import { generateId } from '../../utils.js';
const addressSchema = new mongoose.Schema({
building: { required: false, type: String },
addressLine1: { required: false, type: String },
addressLine2: { required: false, type: String },
city: { required: false, type: String },
state: { required: false, type: String },
postcode: { required: false, type: String },
country: { required: false, type: String },
});
const clientSchema = new mongoose.Schema(
{
_reference: { type: String, default: () => generateId()() },
name: { required: true, type: String },
marketplace: { type: mongoose.Schema.Types.ObjectId, ref: 'marketplace', required: false },
email: { required: false, type: String },
phone: { required: false, type: String },
country: { required: false, type: String },
active: { required: true, type: Boolean, default: true },
address: { required: false, type: addressSchema },
tags: [{ required: false, type: String }],
},
{ timestamps: true }
);
clientSchema.virtual('id').get(function () {
return this._id;
});
clientSchema.set('toJSON', { virtuals: true });
export const clientModel = mongoose.model('client', clientSchema);

View File

@ -1,44 +0,0 @@
import mongoose from 'mongoose';
import { generateId } from '../../utils.js';
const { Schema } = mongoose;
const listingSchema = new Schema(
{
_reference: { type: String, default: () => generateId()() },
product: { type: Schema.Types.ObjectId, ref: 'product', required: false },
vendor: { type: Schema.Types.ObjectId, ref: 'vendor', required: true },
stockLocation: { type: Schema.Types.ObjectId, ref: 'stockLocation', required: true },
marketplace: { type: Schema.Types.ObjectId, ref: 'marketplace', required: true },
title: { type: String, required: false },
state: {
type: {
type: String,
enum: ['draft', 'active', 'inactive', 'deleted', 'suspended', 'syncing'],
default: 'draft',
},
message: { type: String, required: false },
},
url: { type: String, required: false },
price: { type: Number, required: false },
currency: { type: String, required: false },
lastSyncedAt: { type: Date, required: false },
},
{ timestamps: 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;
},
});
export const listingModel = mongoose.model('listing', listingSchema);

View File

@ -1,43 +0,0 @@
import mongoose from 'mongoose';
import { generateId } from '../../utils.js';
const { Schema } = mongoose;
const listingVarientSchema = new Schema(
{
_reference: { type: String, default: () => generateId()() },
listing: { type: Schema.Types.ObjectId, ref: 'listing', required: true },
product: { type: Schema.Types.ObjectId, ref: 'product', required: false },
productSku: { type: Schema.Types.ObjectId, ref: 'productSku', required: false },
state: {
type: {
type: String,
enum: ['draft', 'active', 'inactive', 'deleted', 'suspended', 'syncing'],
default: 'draft',
},
message: { type: String, required: false },
},
price: { type: Number, required: false },
currency: { type: String, required: false },
priceTaxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
priceWithTax: { type: Number, required: false },
lastSyncedAt: { type: Date, required: false },
},
{ timestamps: true }
);
listingVarientSchema.virtual('id').get(function () {
return this._id;
});
listingVarientSchema.set('toJSON', {
virtuals: true,
transform(doc, ret) {
if (!ret.state && ret.status) {
ret.state = { type: ret.status, message: null };
}
if (ret.status) delete ret.status;
return ret;
},
});
export const listingVarientModel = mongoose.model('listingVarient', listingVarientSchema);

View File

@ -1,57 +0,0 @@
import mongoose from 'mongoose';
import { editObject } from '../../database.js';
import { generateId } from '../../utils.js';
const marketplaceSchema = new mongoose.Schema(
{
_reference: { type: String, default: () => generateId()() },
name: { required: true, type: String },
provider: {
type: String,
required: true,
enum: ['ebay', 'etsy', 'tiktokShop'],
},
active: { required: true, type: Boolean, default: true },
connected: { type: Boolean, required: true, default: false },
connectedAt: { type: Date, required: false },
state: {
type: {
type: String,
enum: ['active', 'inactive', 'suspended', 'ready', 'offline', 'syncing'],
default: 'offline',
},
message: { type: String, required: false },
},
// Provider-specific API configuration (flexible for eBay, Etsy, TikTok Shop)
config: { type: mongoose.Schema.Types.Mixed, default: {} },
},
{ timestamps: true }
);
marketplaceSchema.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';
}
console.log('recalculating marketplace state', stateType);
marketplace.state = { type: stateType };
await editObject({
model: this,
id: marketplace._id,
updateData: { state: { type: stateType } },
user,
recalculate: false,
});
};
marketplaceSchema.set('toJSON', { virtuals: true });
export const marketplaceModel = mongoose.model('marketplace', marketplaceSchema);

View File

@ -1,215 +0,0 @@
import mongoose from 'mongoose';
import { generateId } from '../../utils.js';
const { Schema } = mongoose;
import {
aggregateRollups,
aggregateRollupsHistory,
editObject,
} from '../../database.js';
const salesOrderSchema = new Schema(
{
_reference: { type: String, default: () => generateId()() },
totalAmount: { type: Number, required: true, default: 0 },
totalAmountWithTax: { type: Number, required: true, default: 0 },
shippingAmount: { type: Number, required: true, default: 0 },
shippingAmountWithTax: { type: Number, required: true, default: 0 },
grandTotalAmount: { type: Number, required: true, default: 0 },
totalTaxAmount: { type: Number, required: true, default: 0 },
timestamp: { type: Date, default: Date.now },
client: { type: Schema.Types.ObjectId, ref: 'client', required: true },
marketplace: { type: Schema.Types.ObjectId, ref: 'marketplace', required: false },
state: {
type: { type: String, required: true, default: 'draft' },
},
postedAt: { type: Date, required: false },
confirmedAt: { type: Date, required: false },
cancelledAt: { type: Date, required: false },
completedAt: { type: Date, required: false },
},
{ timestamps: true }
);
const rollupConfigs = [
{
name: 'draft',
filter: { 'state.type': 'draft' },
rollups: [{ name: 'draft', property: 'state.type', operation: 'count' }],
},
{
name: 'sent',
filter: { 'state.type': 'sent' },
rollups: [{ name: 'sent', property: 'state.type', operation: 'count' }],
},
{
name: 'confirmed',
filter: { 'state.type': 'confirmed' },
rollups: [{ name: 'confirmed', property: 'state.type', operation: 'count' }],
},
{
name: 'partiallyShipped',
filter: { 'state.type': 'partiallyShipped' },
rollups: [{ name: 'partiallyShipped', property: 'state.type', operation: 'count' }],
},
{
name: 'shipped',
filter: { 'state.type': 'shipped' },
rollups: [{ name: 'shipped', property: 'state.type', operation: 'count' }],
},
{
name: 'partiallyDelivered',
filter: { 'state.type': 'partiallyDelivered' },
rollups: [{ name: 'partiallyDelivered', property: 'state.type', operation: 'count' }],
},
{
name: 'delivered',
filter: { 'state.type': 'delivered' },
rollups: [{ name: 'delivered', property: 'state.type', operation: 'count' }],
},
{
name: 'cancelled',
filter: { 'state.type': 'cancelled' },
rollups: [{ name: 'cancelled', property: 'state.type', operation: 'count' }],
},
{
name: 'completed',
filter: { 'state.type': 'completed' },
rollups: [{ name: 'completed', property: 'state.type', operation: 'count' }],
},
];
salesOrderSchema.statics.stats = async function () {
const results = await aggregateRollups({
model: this,
rollupConfigs: rollupConfigs,
});
// Transform the results to match the expected format
return results;
};
salesOrderSchema.statics.history = async function (from, to) {
const results = await aggregateRollupsHistory({
model: this,
startDate: from,
endDate: to,
rollupConfigs: rollupConfigs,
});
// Return time-series data array
return results;
};
salesOrderSchema.statics.recalculate = async function (salesOrder, user) {
const orderId = salesOrder._id || salesOrder;
if (!orderId) {
return;
}
const orderItemModel = mongoose.model('orderItem');
const shipmentModel = mongoose.model('shipment');
const orderIdObj = new mongoose.Types.ObjectId(orderId);
const baseFilter = { order: orderIdObj, orderType: 'salesOrder' };
const orderItemRollupResults = await aggregateRollups({
model: orderItemModel,
baseFilter,
rollupConfigs: [
{
name: 'orderTotals',
rollups: [
{ name: 'totalAmount', property: 'totalAmount', operation: 'sum' },
{ name: 'totalAmountWithTax', property: 'totalAmountWithTax', operation: 'sum' },
],
},
{
name: 'overallCount',
rollups: [{ name: 'overallCount', property: '_id', operation: 'count' }],
},
{
name: 'shipped',
filter: { 'state.type': 'shipped' },
rollups: [{ name: 'shipped', property: 'state.type', operation: 'count' }],
},
{
name: 'received',
filter: { 'state.type': 'received' },
rollups: [{ name: 'received', property: 'state.type', operation: 'count' }],
},
],
});
const shipmentRollupResults = await aggregateRollups({
model: shipmentModel,
baseFilter,
rollupConfigs: [
{
name: 'shipmentTotals',
rollups: [
{ name: 'amount', property: 'amount', operation: 'sum' },
{ name: 'amountWithTax', property: 'amountWithTax', operation: 'sum' },
],
},
],
});
const orderTotals = orderItemRollupResults.orderTotals || {};
const totalAmount = orderTotals.totalAmount?.sum?.toFixed(2) || 0;
const totalAmountWithTax = orderTotals.totalAmountWithTax?.sum?.toFixed(2) || 0;
const shipmentTotals = shipmentRollupResults.shipmentTotals || {};
const totalShippingAmount = shipmentTotals.amount?.sum?.toFixed(2) || 0;
const totalShippingAmountWithTax = shipmentTotals.amountWithTax?.sum?.toFixed(2) || 0;
const grandTotalAmount =
parseFloat(totalAmountWithTax || 0) + parseFloat(totalShippingAmountWithTax || 0);
const overallCount = orderItemRollupResults.overallCount?.count || 0;
const shippedCount = orderItemRollupResults.shipped?.count || 0;
const receivedCount = orderItemRollupResults.received?.count || 0;
let updateData = {
totalAmount: parseFloat(totalAmount).toFixed(2),
totalAmountWithTax: parseFloat(totalAmountWithTax).toFixed(2),
totalTaxAmount: parseFloat((totalAmountWithTax - totalAmount).toFixed(2)),
shippingAmount: parseFloat(totalShippingAmount).toFixed(2),
shippingAmountWithTax: parseFloat(totalShippingAmountWithTax).toFixed(2),
grandTotalAmount: parseFloat(grandTotalAmount).toFixed(2),
};
if (shippedCount > 0 && shippedCount < overallCount) {
updateData = { ...updateData, state: { type: 'partiallyShipped' } };
}
if (shippedCount > 0 && shippedCount === overallCount) {
updateData = { ...updateData, state: { type: 'shipped' } };
}
if (receivedCount > 0 && receivedCount < overallCount) {
updateData = { ...updateData, state: { type: 'partiallyDelivered' } };
}
if (receivedCount > 0 && receivedCount === overallCount) {
updateData = { ...updateData, state: { type: 'delivered' } };
}
await editObject({
model: this,
id: orderId,
updateData,
user,
recalculate: false,
});
};
// Add virtual id getter
salesOrderSchema.virtual('id').get(function () {
return this._id;
});
// Configure JSON serialization to include virtuals
salesOrderSchema.set('toJSON', { virtuals: true });
// Create and export the model
export const salesOrderModel = mongoose.model('salesOrder', salesOrderSchema);

View File

@ -1,50 +1,7 @@
import { customAlphabet } from 'nanoid';
import mongoose from 'mongoose';
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
export const generateId = () => {
// 10 characters
return customAlphabet(ALPHABET, 12);
};
/** Check if a value is a string that looks like a MongoDB ObjectId (24 hex chars). */
export function isObjectIdString(value) {
return typeof value === 'string' && /^[a-f\d]{24}$/i.test(value);
}
/** Convert a value to ObjectId if it's a valid ObjectId string; otherwise return as-is. */
export function toObjectIdIfValid(value) {
if (isObjectIdString(value)) {
return new mongoose.Types.ObjectId(value);
}
return value;
}
/** Recursively convert ObjectId strings to ObjectId in a filter object for MongoDB $match. */
export function convertObjectIdStringsInFilter(filter) {
if (!filter || typeof filter !== 'object') return filter;
const result = {};
for (const [key, value] of Object.entries(filter)) {
if (key.startsWith('$')) {
if ((key === '$in' || key === '$nin') && Array.isArray(value)) {
result[key] = value.map((v) => (isObjectIdString(v) ? new mongoose.Types.ObjectId(v) : v));
} else if (value && typeof value === 'object' && !Array.isArray(value)) {
result[key] = convertObjectIdStringsInFilter(value);
} else {
result[key] = toObjectIdIfValid(value);
}
} else if (
value &&
typeof value === 'object' &&
!Array.isArray(value) &&
!(value instanceof mongoose.Types.ObjectId) &&
!(value instanceof Date)
) {
result[key] = convertObjectIdStringsInFilter(value);
} else {
result[key] = toObjectIdIfValid(value);
}
}
return result;
}

View File

@ -2,36 +2,28 @@ import express from 'express';
import bodyParser from 'body-parser';
import cors from 'cors';
import config from './config.js';
import { expressSession, keycloak } from './keycloak.js';
import { dbConnect } from './database/mongo.js';
import { redisServer } from './database/redis.js';
import {
authRoutes,
userRoutes,
appPasswordRoutes,
fileRoutes,
printerRoutes,
jobRoutes,
subJobRoutes,
gcodeFileRoutes,
filamentRoutes,
filamentSkuRoutes,
spotlightRoutes,
partRoutes,
partSkuRoutes,
productRoutes,
productCategoryRoutes,
productSkuRoutes,
vendorRoutes,
materialRoutes,
partStockRoutes,
productStockRoutes,
filamentStockRoutes,
purchaseOrderRoutes,
orderItemRoutes,
shipmentRoutes,
stockAuditRoutes,
stockLocationRoutes,
stockTransferRoutes,
stockEventRoutes,
auditLogRoutes,
noteTypeRoutes,
@ -45,22 +37,6 @@ import {
courierServiceRoutes,
taxRateRoutes,
taxRecordRoutes,
invoiceRoutes,
paymentRoutes,
clientRoutes,
salesOrderRoutes,
marketplaceRoutes,
listingRoutes,
listingVarientRoutes,
userNotifierRoutes,
notificationRoutes,
odataRoutes,
rssRoutes,
excelRoutes,
csvRoutes,
appLaunchRoutes,
appUpdateRoutes,
serverRoutes,
} from './routes/index.js';
import path from 'path';
import * as fs from 'fs';
@ -100,9 +76,6 @@ async function initializeApp() {
// Connect to database
await dbConnect();
// Connect to Redis (required for excel temp tokens, sessions, cache)
await redisServer.connect();
// Connect to NATS
await natsServer.connect();
@ -126,6 +99,8 @@ async function initializeApp() {
app.use(cors(corsOptions));
app.use(bodyParser.json({ type: 'application/json', strict: false, limit: '50mb' }));
app.use(express.json());
app.use(expressSession);
app.use(keycloak.middleware());
app.use(populateUserMiddleware);
app.get('/', function (req, res) {
@ -135,7 +110,6 @@ app.get('/', function (req, res) {
app.use('/auth', authRoutes);
app.use('/users', userRoutes);
app.use('/apppasswords', appPasswordRoutes);
app.use('/files', fileRoutes);
app.use('/spotlight', spotlightRoutes);
app.use('/printers', printerRoutes);
@ -144,24 +118,17 @@ app.use('/jobs', jobRoutes);
app.use('/subjobs', subJobRoutes);
app.use('/gcodefiles', gcodeFileRoutes);
app.use('/filaments', filamentRoutes);
app.use('/filamentskus', filamentSkuRoutes);
app.use('/parts', partRoutes);
app.use('/partskus', partSkuRoutes);
app.use('/products', productRoutes);
app.use('/productcategories', productCategoryRoutes);
app.use('/productskus', productSkuRoutes);
app.use('/vendors', vendorRoutes);
app.use('/materials', materialRoutes);
app.use('/partstocks', partStockRoutes);
app.use('/productstocks', productStockRoutes);
app.use('/filamentstocks', filamentStockRoutes);
app.use('/purchaseorders', purchaseOrderRoutes);
app.use('/orderitems', orderItemRoutes);
app.use('/shipments', shipmentRoutes);
app.use('/stockevents', stockEventRoutes);
app.use('/stockaudits', stockAuditRoutes);
app.use('/stocklocations', stockLocationRoutes);
app.use('/stocktransfers', stockTransferRoutes);
app.use('/auditlogs', auditLogRoutes);
app.use('/notetypes', noteTypeRoutes);
app.use('/documentsizes', documentSizesRoutes);
@ -172,27 +139,7 @@ app.use('/couriers', courierRoutes);
app.use('/courierservices', courierServiceRoutes);
app.use('/taxrates', taxRateRoutes);
app.use('/taxrecords', taxRecordRoutes);
app.use('/invoices', invoiceRoutes);
app.use('/payments', paymentRoutes);
app.use('/clients', clientRoutes);
app.use('/salesorders', salesOrderRoutes);
app.use('/marketplaces', marketplaceRoutes);
app.use('/listings', listingRoutes);
app.use('/listingvarients', listingVarientRoutes);
app.use('/notes', noteRoutes);
app.use('/usernotifiers', userNotifierRoutes);
app.use('/notifications', notificationRoutes);
app.use('/odata', odataRoutes);
app.use('/rss', rssRoutes);
app.use('/excel', excelRoutes);
app.use('/csv', csvRoutes);
app.use('/applaunch', appLaunchRoutes);
app.use('/appupdate', appUpdateRoutes);
app.use('/server', serverRoutes);
// Start the application
if (process.env.NODE_ENV !== 'test') {
initializeApp();
}
export default app;
initializeApp();

View File

@ -1,163 +0,0 @@
import crypto from 'crypto';
import {
getApiBaseUrl,
getAuthorizeBaseUrl,
getScopes,
getScopesString,
getTokenExpiryDate,
isAccessTokenExpired,
getRequiredAuthConfig,
getBasicAuthHeader,
logger,
} from './shared.js';
const TOKEN_PATH = '/identity/v1/oauth2/token';
async function mintToken(marketplace, body) {
const response = await fetch(`${getApiBaseUrl(marketplace)}${TOKEN_PATH}`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: getBasicAuthHeader(marketplace),
},
body: new URLSearchParams(body).toString(),
});
const data = await response.json();
if (!response.ok || data.error) {
const message = data.error_description || data.error || response.statusText;
logger.error(`eBay token request failed: ${message}`);
throw new Error(`eBay token request failed: ${message}`);
}
return data;
}
export function createAuthorizationUrl(marketplace, { state } = {}) {
const { clientId } = getRequiredAuthConfig(marketplace);
const { ruName, locale, prompt } = marketplace.config || {};
if (!ruName) {
throw new Error('eBay marketplace is missing required config (ruName)');
}
const url = new URL('/oauth2/authorize', getAuthorizeBaseUrl(marketplace));
url.searchParams.set('client_id', clientId);
url.searchParams.set('redirect_uri', ruName);
url.searchParams.set('response_type', 'code');
url.searchParams.set('scope', getScopesString(marketplace));
if (state) {
url.searchParams.set('state', state);
}
if (locale) {
url.searchParams.set('locale', locale);
}
if (prompt) {
url.searchParams.set('prompt', prompt);
}
return url.toString();
}
export async function exchangeAuthorizationCode(marketplace, { code }) {
const { ruName } = marketplace.config || {};
if (!code) {
throw new Error('Missing eBay authorization code');
}
if (!ruName) {
throw new Error('eBay marketplace is missing required config (ruName)');
}
const tokenData = await mintToken(marketplace, {
grant_type: 'authorization_code',
code,
redirect_uri: ruName,
});
return {
configUpdates: {
accessToken: tokenData.access_token,
accessTokenExpiresAt: getTokenExpiryDate(tokenData.expires_in).toISOString(),
refreshToken: tokenData.refresh_token || marketplace.config.refreshToken,
scopes: getScopes(marketplace),
tokenType: tokenData.token_type,
},
marketplaceUpdates: {
connected: true,
connectedAt: new Date(),
},
data: {
expiresIn: tokenData.expires_in,
tokenType: tokenData.token_type,
},
};
}
export async function refreshAuth(marketplace) {
const { refreshToken } = marketplace.config || {};
if (!refreshToken) {
throw new Error('eBay marketplace is missing required config (refreshToken)');
}
const tokenData = await mintToken(marketplace, {
grant_type: 'refresh_token',
refresh_token: refreshToken,
scope: getScopesString(marketplace),
});
return {
configUpdates: {
accessToken: tokenData.access_token,
accessTokenExpiresAt: getTokenExpiryDate(tokenData.expires_in).toISOString(),
refreshToken: tokenData.refresh_token || refreshToken,
scopes: getScopes(marketplace),
tokenType: tokenData.token_type,
lastTokenRefreshAt: new Date().toISOString(),
},
data: {
expiresIn: tokenData.expires_in,
tokenType: tokenData.token_type,
},
};
}
export async function ensureAuthenticatedMarketplace(marketplace) {
if (!isAccessTokenExpired(marketplace)) {
return { marketplace };
}
const authResult = await refreshAuth(marketplace);
return {
marketplace: {
...marketplace,
config: {
...(marketplace.config || {}),
...authResult.configUpdates,
},
},
configUpdates: authResult.configUpdates,
};
}
export function canVerifyWebhookSignature(marketplace) {
return !!marketplace.config?.verificationToken;
}
export function verifyWebhookSignature(marketplace, rawBody, signature) {
const verificationToken = marketplace.config?.verificationToken;
if (!verificationToken) {
return false;
}
const hash = crypto
.createHash('sha256')
.update(rawBody + verificationToken)
.digest('base64');
try {
return crypto.timingSafeEqual(Buffer.from(hash), Buffer.from(signature));
} catch {
return false;
}
}

View File

@ -1,102 +0,0 @@
/**
* Maps FarmControl country codes (farmcontrol-ui/src/database/Countries.js)
* to eBay Sell Inventory API CountryCodeEnum values.
* @see https://developer.ebay.com/api-docs/sell/inventory/types/ba:CountryCodeEnum
*/
/** @type {Set<string>} */
export const EBAY_COUNTRY_CODES = new Set([
'AD', 'AE', 'AF', 'AG', 'AI', 'AL', 'AM', 'AN', 'AO', 'AQ', 'AR', 'AS', 'AT', 'AU', 'AW', 'AX', 'AZ',
'BA', 'BB', 'BD', 'BE', 'BF', 'BG', 'BH', 'BI', 'BJ', 'BL', 'BM', 'BN', 'BO', 'BQ', 'BR', 'BS', 'BT',
'BV', 'BW', 'BY', 'BZ', 'CA', 'CC', 'CD', 'CF', 'CG', 'CH', 'CI', 'CK', 'CL', 'CM', 'CN', 'CO', 'CR',
'CU', 'CV', 'CW', 'CX', 'CY', 'CZ', 'DE', 'DJ', 'DK', 'DM', 'DO', 'DZ', 'EC', 'EE', 'EG', 'EH', 'ER',
'ES', 'ET', 'FI', 'FJ', 'FK', 'FM', 'FO', 'FR', 'GA', 'GB', 'GD', 'GE', 'GF', 'GG', 'GH', 'GI', 'GL',
'GM', 'GN', 'GP', 'GQ', 'GR', 'GS', 'GT', 'GU', 'GW', 'GY', 'HK', 'HM', 'HN', 'HR', 'HT', 'HU', 'ID',
'IE', 'IL', 'IM', 'IN', 'IO', 'IQ', 'IR', 'IS', 'IT', 'JE', 'JM', 'JO', 'JP', 'KE', 'KG', 'KH', 'KI',
'KM', 'KN', 'KP', 'KR', 'KW', 'KY', 'KZ', 'LA', 'LB', 'LC', 'LI', 'LK', 'LR', 'LS', 'LT', 'LU', 'LV',
'LY', 'MA', 'MC', 'MD', 'ME', 'MF', 'MG', 'MH', 'MK', 'ML', 'MM', 'MN', 'MO', 'MP', 'MQ', 'MR', 'MS',
'MT', 'MU', 'MV', 'MW', 'MX', 'MY', 'MZ', 'NA', 'NC', 'NE', 'NF', 'NG', 'NI', 'NL', 'NO', 'NP', 'NR',
'NU', 'NZ', 'OM', 'PA', 'PE', 'PF', 'PG', 'PH', 'PK', 'PL', 'PM', 'PN', 'PR', 'PS', 'PT', 'PW', 'PY',
'QA', 'RE', 'RO', 'RS', 'RU', 'RW', 'SA', 'SB', 'SC', 'SD', 'SE', 'SG', 'SH', 'SI', 'SJ', 'SK', 'SL',
'SM', 'SN', 'SO', 'SR', 'ST', 'SV', 'SX', 'SY', 'SZ', 'TC', 'TD', 'TF', 'TG', 'TH', 'TJ', 'TK', 'TL',
'TM', 'TN', 'TO', 'TR', 'TT', 'TV', 'TW', 'TZ', 'UA', 'UG', 'UM', 'US', 'UY', 'UZ', 'VA', 'VC', 'VE',
'VG', 'VI', 'VN', 'VU', 'WF', 'WS', 'YE', 'YT', 'ZA', 'ZM', 'ZW',
]);
/**
* FarmControl-only codes (Countries.js) that must map to an eBay enum value.
*/
const FARMCONTROL_TO_EBAY = {
UK: 'GB',
'GB-ENG': 'GB',
'GB-NIR': 'GB',
'GB-SCT': 'GB',
'GB-UKM': 'GB',
'GB-WLS': 'GB',
'BQ-BO': 'BQ',
'BQ-SA': 'BQ',
'BQ-SE': 'BQ',
};
/**
* FarmControl codes with no eBay equivalent sync/publish will fail with a clear message.
*/
const FARMCONTROL_UNSUPPORTED_ON_EBAY = new Set(['SS']);
/** State/province hints when a UK subdivision code is mapped to GB. */
export const FARMCONTROL_GB_SUBDIVISION_STATE = {
'GB-ENG': 'England',
'GB-NIR': 'Northern Ireland',
'GB-SCT': 'Scotland',
'GB-WLS': 'Wales',
'GB-UKM': 'England',
};
/**
* @param {string | null | undefined} farmControlCountryCode
* @returns {string | null} eBay CountryCodeEnum or null if missing/blank
*/
export function toEbayCountryCode(farmControlCountryCode) {
if (farmControlCountryCode == null || typeof farmControlCountryCode !== 'string') {
return null;
}
const normalized = farmControlCountryCode.trim().toUpperCase();
if (!normalized) {
return null;
}
if (FARMCONTROL_UNSUPPORTED_ON_EBAY.has(normalized)) {
throw new Error(
`Country "${normalized}" is not supported by eBay. Choose a different stock location country.`
);
}
const mapped = FARMCONTROL_TO_EBAY[normalized];
if (mapped) {
return mapped;
}
if (EBAY_COUNTRY_CODES.has(normalized)) {
return normalized;
}
throw new Error(
`Country "${normalized}" is not supported by eBay. Use a country from the stock location address list.`
);
}
/**
* @param {string | null | undefined} farmControlCountryCode
* @returns {{ ebayCountryCode: string, farmControlCountryCode: string } | null}
*/
export function resolveEbayCountry(farmControlCountryCode) {
const ebayCountryCode = toEbayCountryCode(farmControlCountryCode);
if (!ebayCountryCode) {
return null;
}
return {
ebayCountryCode,
farmControlCountryCode: farmControlCountryCode.trim().toUpperCase(),
};
}

View File

@ -1,28 +0,0 @@
export {
createAuthorizationUrl,
exchangeAuthorizationCode,
refreshAuth,
ensureAuthenticatedMarketplace,
canVerifyWebhookSignature,
verifyWebhookSignature,
} from './auth.js';
export {
syncItems,
mapProductToListing,
createItem,
updateItem,
deleteItem,
publishOfferById,
withdrawOfferById,
publishOfferForSku,
withdrawOfferForSku,
} from './listings.js';
export {
syncOrders,
mapOrderStatus,
mapOrderToSalesOrder,
mapBuyerToClient,
handleWebhook,
} from './orders.js';

View File

@ -1,744 +0,0 @@
import mongoose from 'mongoose';
import { stockLocationModel } from '../../../database/schemas/inventory/stocklocation.schema.js';
import {
FARMCONTROL_GB_SUBDIVISION_STATE,
resolveEbayCountry,
} from './countryCodes.js';
import { makeRequest, logger } from './shared.js';
const WAREHOUSE_ADDRESS_DEFAULTS = {
GB: { city: 'London', stateOrProvince: 'England', postalCode: 'SW1A 1AA' },
US: { city: 'New York', stateOrProvince: 'NY', postalCode: '10001' },
AU: { city: 'Sydney', stateOrProvince: 'NSW', postalCode: '2000' },
CA: { city: 'Toronto', stateOrProvince: 'ON', postalCode: 'M5H 2N2' },
DE: { city: 'Berlin', stateOrProvince: 'Berlin', postalCode: '10115' },
FR: { city: 'Paris', stateOrProvince: 'Île-de-France', postalCode: '75001' },
};
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function isPopulatedStockLocation(stockLocation) {
if (!stockLocation || typeof stockLocation !== 'object') {
return false;
}
if (stockLocation instanceof mongoose.Types.ObjectId) {
return false;
}
return stockLocation.name != null || stockLocation.address != null;
}
async function ensureListingStockLocation(listing) {
const stockLocationRef = listing?.stockLocation;
if (!stockLocationRef) {
return listing;
}
if (isPopulatedStockLocation(stockLocationRef)) {
return listing;
}
const stockLocationId = stockLocationRef._id || stockLocationRef;
const stockLocation = await stockLocationModel.findById(stockLocationId).lean();
if (!stockLocation) {
throw new Error('Listing stock location not found.');
}
return { ...listing, stockLocation };
}
export function resolveStockLocationCountryCode(listing) {
const resolved = resolveStockLocationEbayCountry(listing);
return resolved?.ebayCountryCode ?? null;
}
export function resolveStockLocationEbayCountry(listing) {
const stockLocation = listing?.stockLocation;
if (!isPopulatedStockLocation(stockLocation)) {
return null;
}
try {
return resolveEbayCountry(stockLocation.address?.country);
} catch {
return null;
}
}
function resolveMerchantLocationKey(listing) {
const stockLocationId = listing?.stockLocation?._id || listing?.stockLocation;
if (!stockLocationId) {
return null;
}
return `fc-${String(stockLocationId)}`.slice(0, 36);
}
function buildEbayWarehouseAddress(addr, ebayCountryCode, farmControlCountryCode) {
const address = {
country: ebayCountryCode,
};
if (addr?.addressLine1) address.addressLine1 = addr.addressLine1;
if (addr?.addressLine2) address.addressLine2 = addr.addressLine2;
if (addr?.city) address.city = addr.city;
if (addr?.state) address.stateOrProvince = addr.state;
if (addr?.postcode) address.postalCode = addr.postcode;
if (
!address.stateOrProvince &&
farmControlCountryCode &&
FARMCONTROL_GB_SUBDIVISION_STATE[farmControlCountryCode]
) {
address.stateOrProvince = FARMCONTROL_GB_SUBDIVISION_STATE[farmControlCountryCode];
}
const hasPostal = Boolean(address.postalCode);
const hasCityState = Boolean(address.city && address.stateOrProvince);
if (!hasPostal && !hasCityState) {
const defaults = WAREHOUSE_ADDRESS_DEFAULTS[ebayCountryCode];
if (defaults) {
Object.assign(address, defaults);
if (
farmControlCountryCode &&
FARMCONTROL_GB_SUBDIVISION_STATE[farmControlCountryCode]
) {
address.stateOrProvince = FARMCONTROL_GB_SUBDIVISION_STATE[farmControlCountryCode];
}
} else {
address.city = address.city || 'Unknown';
address.stateOrProvince = address.stateOrProvince || ebayCountryCode;
address.postalCode = address.postalCode || '00000';
}
}
if (!address.country) {
throw new Error('eBay warehouse address requires a country code.');
}
return address;
}
function buildWarehouseLocationBody(listing, ebayCountryCode, farmControlCountryCode) {
const stockLocation = listing.stockLocation;
const addr = stockLocation?.address || {};
const address = buildEbayWarehouseAddress(addr, ebayCountryCode, farmControlCountryCode);
return {
location: { address },
locationTypes: ['WAREHOUSE'],
name: stockLocation?.name || `FarmControl ${ebayCountryCode}`,
};
}
async function ensureMerchantLocation(marketplace, listing) {
const listingWithStockLocation = await ensureListingStockLocation(listing);
const countryResolved = resolveEbayCountry(
listingWithStockLocation.stockLocation?.address?.country
);
if (!countryResolved) {
throw new Error(
'Listing stock location address must include a country before syncing or publishing on eBay.'
);
}
const { ebayCountryCode, farmControlCountryCode } = countryResolved;
const merchantLocationKey = resolveMerchantLocationKey(listingWithStockLocation);
if (!merchantLocationKey) {
throw new Error('Listing must have a stock location before syncing or publishing on eBay.');
}
const locationBody = buildWarehouseLocationBody(
listingWithStockLocation,
ebayCountryCode,
farmControlCountryCode
);
listing.stockLocation = listingWithStockLocation.stockLocation;
const locationPath = `/sell/inventory/v1/location/${encodeURIComponent(merchantLocationKey)}`;
const existing = await makeRequest({
marketplace,
path: locationPath,
acceptableStatuses: [404],
});
if (existing) {
await makeRequest({
marketplace,
method: 'POST',
path: `${locationPath}/update_location_details`,
body: { location: locationBody.location },
});
} else {
await makeRequest({
marketplace,
method: 'POST',
path: locationPath,
body: locationBody,
});
}
return merchantLocationKey;
}
function applyMarketplaceOfferDefaults(offer, marketplace, merchantLocationKey) {
offer.merchantLocationKey = merchantLocationKey;
const config = marketplace.config || {};
const listingPolicies = {};
if (config.fulfillmentPolicyId) {
listingPolicies.fulfillmentPolicyId = config.fulfillmentPolicyId;
}
if (config.paymentPolicyId) {
listingPolicies.paymentPolicyId = config.paymentPolicyId;
}
if (config.returnPolicyId) {
listingPolicies.returnPolicyId = config.returnPolicyId;
}
if (Object.keys(listingPolicies).length > 0) {
offer.listingPolicies = listingPolicies;
}
}
function mapVarientToInventoryItem(varient, listing) {
const item = {
product: {
title: listing.title || varient._reference || '',
},
availability: {
shipToLocationAvailability: {
quantity: varient.inventory ?? 0,
},
},
};
if (listing.description) {
item.product.description = listing.description;
}
if (listing.imageUrls?.length) {
item.product.imageUrls = listing.imageUrls;
}
return item;
}
function mapVarientToOffer(varient, listing, marketplace, merchantLocationKey) {
const offer = {
sku: varient._reference,
marketplaceId: marketplace.config?.marketplaceId || 'EBAY_GB',
format: 'FIXED_PRICE',
};
applyMarketplaceOfferDefaults(offer, marketplace, merchantLocationKey);
const price = varient.price ?? listing.price;
if (price != null) {
offer.pricingSummary = {
price: {
value: String(price),
currency: varient.currency || listing.currency || 'GBP',
},
};
}
if (listing.categoryId) {
offer.categoryId = listing.categoryId;
}
return offer;
}
async function upsertInventoryItem(marketplace, varient, listing) {
const inventoryItem = mapVarientToInventoryItem(varient, listing);
const result = await makeRequest({
marketplace,
method: 'PUT',
path: `/sell/inventory/v1/inventory_item/${encodeURIComponent(varient._reference)}`,
body: inventoryItem,
});
logger.debug('inventoryItem', inventoryItem);
logger.debug('result', result);
}
async function upsertOrCreateOffer(marketplace, varient, listing) {
const merchantLocationKey = await ensureMerchantLocation(marketplace, listing);
const offers = await fetchOffers(marketplace, varient._reference);
const existingOffer = offers[0];
const price = varient.price ?? listing.price;
if (existingOffer?.offerId) {
const offerUpdate = { merchantLocationKey };
if (price != null) {
offerUpdate.pricingSummary = {
price: {
value: String(price),
currency: varient.currency || listing.currency || 'GBP',
},
};
}
await makeRequest({
marketplace,
method: 'PUT',
path: `/sell/inventory/v1/offer/${existingOffer.offerId}`,
body: { ...existingOffer, ...offerUpdate },
});
logger.debug('offerUpdate', { ...existingOffer, ...offerUpdate });
return existingOffer;
}
const offerBody = mapVarientToOffer(varient, listing, marketplace, merchantLocationKey);
const result = await makeRequest({
marketplace,
method: 'POST',
path: '/sell/inventory/v1/offer',
body: offerBody,
});
logger.debug('offerBody', offerBody);
return result;
}
async function createOrReplaceGroup(marketplace, listing, varients) {
const groupKey = listing._reference;
const variantSKUs = varients.map((v) => v._reference).filter(Boolean);
const body = {
title: listing.title || groupKey,
variantSKUs,
};
if (listing.description) {
body.description = listing.description;
}
if (listing.imageUrls?.length) {
body.imageUrls = listing.imageUrls;
}
await makeRequest({
marketplace,
method: 'PUT',
path: `/sell/inventory/v1/inventory_item_group/${encodeURIComponent(groupKey)}`,
body,
});
}
async function deleteGroup(marketplace, groupKey) {
try {
await makeRequest({
marketplace,
method: 'DELETE',
path: `/sell/inventory/v1/inventory_item_group/${encodeURIComponent(groupKey)}`,
acceptableStatuses: [404],
});
} catch (err) {
logger.warn(`Failed to delete inventory item group "${groupKey}": ${err.message}`);
}
}
async function syncOfferAndMaybePublish(marketplace, listing, varient) {
const offerResult = await upsertOrCreateOffer(marketplace, varient, listing);
if (offerResult?.offerId && listing.state?.type === 'active') {
try {
const publishResult = await publishOfferById(marketplace, offerResult.offerId);
if (publishResult?.listingId) {
return `https://www.ebay.com/itm/${publishResult.listingId}`;
}
} catch (err) {
logger.warn(
`Created offer but failed to publish for varient ${varient._reference}: ${err.message}`
);
}
}
return '';
}
async function syncSingleVarientListing(marketplace, listing, varient) {
await upsertInventoryItem(marketplace, varient, listing);
// If this listing used to be grouped, remove the stale group before treating it as a standalone item.
if (listing._reference) {
const existingGroup = await safeFetchInventoryItemGroup(marketplace, listing._reference);
if (existingGroup) {
await deleteGroup(marketplace, listing._reference);
}
}
return syncOfferAndMaybePublish(marketplace, listing, varient);
}
async function syncGroupedListing(marketplace, listing, varients) {
logger.info(
`Syncing eBay inventory item group "${listing._reference}" with ${varients.length} varient(s)`
);
for (const varient of varients) {
await upsertInventoryItem(marketplace, varient, listing);
}
// Brief delay so eBay can resolve the new inventory item SKUs before creating the group.
await sleep(1000);
await createOrReplaceGroup(marketplace, listing, varients);
let firstPublishedUrl = '';
for (const varient of varients) {
try {
const publishedUrl = await syncOfferAndMaybePublish(marketplace, listing, varient);
if (publishedUrl && !firstPublishedUrl) firstPublishedUrl = publishedUrl;
} catch (err) {
logger.warn(`Failed to create offer for varient ${varient._reference}: ${err.message}`);
}
}
return firstPublishedUrl;
}
async function syncListing(marketplace, listing, varients, actionLabel) {
const ref = listing._reference;
if (!ref) {
throw new Error(`Listing must have a _reference to ${actionLabel} on eBay`);
}
const validVarients = (varients || []).filter((varient) => varient?._reference);
if (validVarients.length === 0) {
throw new Error(
`Listing must have at least one varient with a _reference to ${actionLabel} on eBay`
);
}
if (validVarients.length === 1) {
logger.info(
`Syncing standalone eBay inventory item "${validVarients[0]._reference}" for listing "${ref}"`
);
const url = await syncSingleVarientListing(marketplace, listing, validVarients[0]);
return { url };
}
const url = await syncGroupedListing(marketplace, listing, validVarients);
return { url };
}
export async function createItem(marketplace, listing, varients) {
return syncListing(marketplace, listing, varients, 'create');
}
export async function updateItem(marketplace, listing, varients) {
return syncListing(marketplace, listing, varients, 'update');
}
export async function deleteItem(marketplace, listing) {
const ref = listing._reference;
if (!ref) return;
logger.info(`Deleting eBay inventory item group "${ref}"`);
await deleteGroup(marketplace, ref);
}
// --- Sync helpers (inbound from eBay) ---
async function fetchAllInventoryItems(marketplace) {
const items = [];
let offset = 0;
const limit = 100;
do {
const data = await makeRequest({
marketplace,
path: '/sell/inventory/v1/inventory_item',
params: { limit, offset },
});
if (data?.inventoryItems?.length) {
items.push(...data.inventoryItems);
}
if (!data?.inventoryItems?.length || items.length >= (data.total || 0)) {
break;
}
offset += limit;
} while (true);
return items;
}
async function fetchOffers(marketplace, sku) {
try {
const data = await makeRequest({
marketplace,
path: '/sell/inventory/v1/offer',
params: { sku, limit: 200 },
acceptableStatuses: [404],
});
return data?.offers || [];
} catch (err) {
logger.debug(`No offers found for SKU ${sku}: ${err.message}`);
return [];
}
}
/**
* eBay Sell Inventory API publish offer (creates live listing).
* @see https://developer.ebay.com/api-docs/sell/inventory/resources/offer/methods/publishOffer
*/
export async function publishOfferById(marketplace, offerId) {
if (!offerId) {
throw new Error('offerId is required to publish an offer');
}
return makeRequest({
marketplace,
method: 'POST',
path: `/sell/inventory/v1/offer/${encodeURIComponent(offerId)}/publish`,
});
}
/**
* eBay Sell Inventory API withdraw offer (ends live listing; offer remains for re-publish).
* @see https://developer.ebay.com/api-docs/sell/inventory/resources/offer/methods/withdrawOffer
*/
export async function withdrawOfferById(marketplace, offerId) {
if (!offerId) {
throw new Error('offerId is required to withdraw an offer');
}
return makeRequest({
marketplace,
method: 'POST',
path: `/sell/inventory/v1/offer/${encodeURIComponent(offerId)}/withdraw`,
});
}
export async function publishOfferForSku(marketplace, sku, listing) {
if (!sku) {
throw new Error('SKU (_reference) is required to publish an offer');
}
if (!listing) {
throw new Error(
'Listing is required to publish an eBay offer (stock location address is used for item location).'
);
}
const merchantLocationKey = await ensureMerchantLocation(marketplace, listing);
const offers = await fetchOffers(marketplace, sku);
const existingOffer = offers[0];
if (!existingOffer?.offerId) {
throw new Error(
`No eBay offer exists for SKU "${sku}". Create or sync the listing so an offer exists before publishing.`
);
}
if (existingOffer.merchantLocationKey !== merchantLocationKey) {
await makeRequest({
marketplace,
method: 'PUT',
path: `/sell/inventory/v1/offer/${existingOffer.offerId}`,
body: { ...existingOffer, merchantLocationKey },
});
}
const publishResult = await publishOfferById(marketplace, existingOffer.offerId);
return {
offerId: existingOffer.offerId,
listingId: publishResult?.listingId,
};
}
export async function withdrawOfferForSku(marketplace, sku) {
if (!sku) {
throw new Error('SKU (_reference) is required to withdraw an offer');
}
const offers = await fetchOffers(marketplace, sku);
const existingOffer = offers[0];
if (!existingOffer?.offerId) {
throw new Error(`No eBay offer exists for SKU "${sku}".`);
}
await withdrawOfferById(marketplace, existingOffer.offerId);
return { offerId: existingOffer.offerId };
}
async function safeFetchInventoryItemGroup(marketplace, groupKey) {
try {
return await makeRequest({
marketplace,
path: `/sell/inventory/v1/inventory_item_group/${encodeURIComponent(groupKey)}`,
acceptableStatuses: [404],
});
} catch (err) {
logger.warn(`Failed to fetch inventory item group "${groupKey}": ${err.message}`);
return null;
}
}
export async function syncItems(marketplace) {
logger.info(`Syncing inventory from eBay marketplace: ${marketplace.name}`);
const inventoryItems = await fetchAllInventoryItems(marketplace);
const itemsBySku = new Map();
const groupKeysSet = new Set();
const groupedSkus = new Set();
for (const item of inventoryItems) {
itemsBySku.set(item.sku, item);
if (item.inventoryItemGroupKeys?.length) {
for (const key of item.inventoryItemGroupKeys) {
groupKeysSet.add(key);
}
}
}
const results = [];
for (const groupKey of groupKeysSet) {
const group = await safeFetchInventoryItemGroup(marketplace, groupKey);
if (!group) continue;
const variantSkus = group.variantSKUs || [];
for (const sku of variantSkus) {
groupedSkus.add(sku);
}
const variantItems = [];
for (const sku of variantSkus) {
const item = itemsBySku.get(sku);
if (item) {
try {
const offers = await fetchOffers(marketplace, sku);
variantItems.push({ ...item, _offers: offers });
} catch (err) {
logger.warn(`Failed to fetch offers for group variant SKU ${sku}: ${err.message}`);
variantItems.push({ ...item, _offers: [] });
}
}
}
results.push({
_type: 'group',
_groupKey: groupKey,
_group: group,
_variants: variantItems,
});
}
for (const item of inventoryItems) {
if (groupedSkus.has(item.sku)) continue;
try {
const offers = await fetchOffers(marketplace, item.sku);
results.push({
_type: 'single',
...item,
_offers: offers,
});
} catch (err) {
logger.warn(`Failed to fetch offers for SKU ${item.sku}: ${err.message}`);
results.push({
_type: 'single',
...item,
_offers: [],
});
}
}
logger.info(
`Fetched ${results.length} listing(s) from eBay (${groupKeysSet.size} group(s), ${results.length - groupKeysSet.size} standalone)`
);
return results;
}
const LISTING_STATUS_MAP = {
ACTIVE: 'active',
OUT_OF_STOCK: 'inactive',
ENDED: 'inactive',
PUBLISHED: 'active',
UNPUBLISHED: 'draft',
};
function resolveOfferState(offers) {
let stateType = 'draft';
for (const offer of offers) {
if (offer?.status && LISTING_STATUS_MAP[offer.status]) {
const mapped = LISTING_STATUS_MAP[offer.status];
if (mapped === 'active') return 'active';
if (mapped !== 'draft') stateType = mapped;
}
}
return stateType;
}
function buildVarientEntry(item, offers) {
const offer = offers?.[0];
const price = offer?.pricingSummary?.price?.value
? parseFloat(offer.pricingSummary.price.value)
: undefined;
const currency = offer?.pricingSummary?.price?.currency || undefined;
return {
_reference: item.sku,
price,
currency,
state: { type: resolveOfferState(offers || []) },
};
}
export function mapProductToListing(ebayItem) {
if (ebayItem._type === 'group') {
const group = ebayItem._group;
const variants = ebayItem._variants || [];
const allOffers = variants.flatMap((v) => v._offers || []);
const stateType = resolveOfferState(allOffers);
const firstPublishedOffer = allOffers.find((o) => o?.listingId);
const url = firstPublishedOffer?.listingId
? `https://www.ebay.com/itm/${firstPublishedOffer.listingId}`
: '';
const firstOffer = allOffers[0];
const price = firstOffer?.pricingSummary?.price?.value
? parseFloat(firstOffer.pricingSummary.price.value)
: undefined;
const currency = firstOffer?.pricingSummary?.price?.currency || undefined;
const varients = variants.map((v) => buildVarientEntry(v, v._offers || []));
return {
_reference: ebayItem._groupKey,
title: group.title || ebayItem._groupKey,
state: { type: stateType },
price,
currency,
url,
varients,
};
}
const offer = ebayItem._offers?.[0];
const price = offer?.pricingSummary?.price?.value
? parseFloat(offer.pricingSummary.price.value)
: undefined;
const currency = offer?.pricingSummary?.price?.currency || undefined;
let stateType = 'draft';
if (offer?.status && LISTING_STATUS_MAP[offer.status]) {
stateType = LISTING_STATUS_MAP[offer.status];
}
const url = offer?.listingId ? `https://www.ebay.com/itm/${offer.listingId}` : '';
const varients = [buildVarientEntry(ebayItem, ebayItem._offers || [])];
return {
_reference: ebayItem.sku,
title: ebayItem.product?.title || ebayItem.sku,
state: { type: stateType },
price,
currency,
url,
varients,
};
}

View File

@ -1,156 +0,0 @@
import { makeRequest, logger } from './shared.js';
async function fetchAllOrders(marketplace, { startTime, endTime } = {}) {
const orders = [];
let offset = 0;
const limit = 50;
const filterParts = [];
if (startTime) {
const isoStart = new Date(startTime * 1000).toISOString();
filterParts.push(`creationdate:[${isoStart}..`);
}
if (endTime) {
const isoEnd = new Date(endTime * 1000).toISOString();
if (filterParts.length && filterParts[0].startsWith('creationdate:')) {
filterParts[0] = filterParts[0] + `${isoEnd}]`;
} else {
filterParts.push(`creationdate:[..${isoEnd}]`);
}
} else if (filterParts.length) {
filterParts[0] = filterParts[0] + ']';
}
do {
const params = { limit, offset };
if (filterParts.length) {
params.filter = filterParts.join(',');
}
const data = await makeRequest({
marketplace,
path: '/sell/fulfillment/v1/order',
params,
});
if (data?.orders?.length) {
orders.push(...data.orders);
}
if (!data?.orders?.length || orders.length >= (data.total || 0)) {
break;
}
offset += limit;
} while (true);
return orders;
}
export async function syncOrders(marketplace, { startTime, endTime } = {}) {
logger.info(`Syncing orders from eBay marketplace: ${marketplace.name}`);
const orders = await fetchAllOrders(marketplace, { startTime, endTime });
logger.info(`Fetched ${orders.length} order(s) from eBay`);
return orders;
}
const ORDER_STATUS_MAP = {
NOT_STARTED: 'draft',
IN_PROGRESS: 'confirmed',
FULFILLED: 'shipped',
CANCELLED: 'cancelled',
};
const FULFILLMENT_STATUS_MAP = {
NOT_STARTED: 'confirmed',
IN_PROGRESS: 'shipped',
FULFILLED: 'delivered',
};
export function mapOrderStatus(ebayOrder) {
if (ebayOrder.cancelStatus?.cancelState === 'CANCELED') {
return 'cancelled';
}
const fulfillmentStatus = ebayOrder.fulfillmentStartInstructions?.[0]?.fulfillmentStatus;
if (fulfillmentStatus && FULFILLMENT_STATUS_MAP[fulfillmentStatus]) {
return FULFILLMENT_STATUS_MAP[fulfillmentStatus];
}
return ORDER_STATUS_MAP[ebayOrder.orderFulfillmentStatus] || 'draft';
}
export function mapOrderToSalesOrder(ebayOrder) {
const pricingSummary = ebayOrder.pricingSummary || {};
const totalAmount = parseFloat(pricingSummary.priceSubtotal?.value || 0);
const shippingAmount = parseFloat(pricingSummary.deliveryCost?.value || 0);
const totalTax = parseFloat(pricingSummary.tax?.value || 0);
const grandTotal = parseFloat(pricingSummary.total?.value || 0);
return {
externalId: ebayOrder.orderId,
state: { type: mapOrderStatus(ebayOrder) },
totalAmount,
totalAmountWithTax: totalAmount + totalTax,
shippingAmount,
shippingAmountWithTax: shippingAmount,
grandTotalAmount: grandTotal,
totalTaxAmount: totalTax,
};
}
export function mapBuyerToClient(ebayOrder) {
const buyer = ebayOrder.buyer || {};
const address = ebayOrder.fulfillmentStartInstructions?.[0]?.shippingStep?.shipTo || {};
const fullName = address.fullName || buyer.username || 'Unknown';
const contactAddress = address.contactAddress || {};
return {
name: fullName,
email: buyer.buyerRegistrationAddress?.email || '',
phone: address.primaryPhone?.phoneNumber || '',
address: {
addressLine1: contactAddress.addressLine1 || '',
addressLine2: contactAddress.addressLine2 || '',
city: contactAddress.city || '',
state: contactAddress.stateOrProvince || '',
postcode: contactAddress.postalCode || '',
country: contactAddress.countryCode || '',
},
};
}
export async function handleWebhook(marketplace, event) {
const { topic, data } = event;
logger.info(`eBay webhook received: ${topic} for marketplace ${marketplace.name}`);
if (topic?.startsWith('marketplace.account_deletion')) {
return { action: 'accountDeletion', userId: data?.userId };
}
switch (topic) {
case 'item.sold':
return { action: 'orderCreate', orderId: data?.orderId };
case 'item.created':
case 'item.updated':
return { action: 'productUpdate', itemId: data?.itemId };
case 'item.ended':
return { action: 'productEnded', itemId: data?.itemId };
case 'order.cancelled':
return { action: 'orderCancel', orderId: data?.orderId };
case 'order.fulfillment':
return { action: 'orderUpdate', orderId: data?.orderId };
default:
logger.debug(`Unhandled eBay webhook topic: ${topic}`);
return { action: 'unknown', topic };
}
}

View File

@ -1,172 +0,0 @@
import config from '../../../config.js';
import log4js from 'log4js';
const logger = log4js.getLogger('eBay');
logger.level = config.server.logLevel;
const SANDBOX_API_URL = 'https://api.sandbox.ebay.com';
const PRODUCTION_API_URL = 'https://api.ebay.com';
const SANDBOX_AUTH_URL = 'https://auth.sandbox.ebay.com';
const PRODUCTION_AUTH_URL = 'https://auth.ebay.com';
const TOKEN_PATH = '/identity/v1/oauth2/token';
const DEFAULT_SCOPES = [
'https://api.ebay.com/oauth/api_scope',
'https://api.ebay.com/oauth/api_scope/sell.inventory',
'https://api.ebay.com/oauth/api_scope/sell.fulfillment',
'https://api.ebay.com/oauth/api_scope/sell.account',
];
const MARKETPLACE_LANGUAGE_MAP = {
EBAY_US: 'en-US',
EBAY_GB: 'en-GB',
EBAY_AU: 'en-AU',
EBAY_CA: 'en-CA',
EBAY_DE: 'de-DE',
EBAY_FR: 'fr-FR',
EBAY_ES: 'es-ES',
EBAY_IT: 'it-IT',
EBAY_NL: 'nl-NL',
EBAY_BE: 'nl-BE',
};
export function getApiBaseUrl(marketplace) {
return marketplace.config.sandbox ? SANDBOX_API_URL : PRODUCTION_API_URL;
}
export function getAuthorizeBaseUrl(marketplace) {
return marketplace.config.sandbox ? SANDBOX_AUTH_URL : PRODUCTION_AUTH_URL;
}
export function getScopes(marketplace) {
if (Array.isArray(marketplace.config.scopes) && marketplace.config.scopes.length) {
return marketplace.config.scopes;
}
if (typeof marketplace.config.scopes === 'string' && marketplace.config.scopes.trim()) {
return marketplace.config.scopes.trim().split(/\s+/);
}
return DEFAULT_SCOPES;
}
export function getScopesString(marketplace) {
return getScopes(marketplace).join(' ');
}
function isValidLanguageTag(value) {
return /^[a-z]{2,3}(?:-[A-Z]{2})?$/.test(value || '');
}
export function getAcceptLanguage(marketplace) {
const configured =
marketplace.config?.acceptLanguage ||
marketplace.config?.locale ||
MARKETPLACE_LANGUAGE_MAP[marketplace.config?.marketplaceId];
if (typeof configured === 'string') {
const normalized = configured.replace('_', '-').trim();
if (isValidLanguageTag(normalized)) {
return normalized;
}
}
return 'en-GB';
}
export function getTokenExpiryDate(expiresInSeconds) {
return new Date(Date.now() + Math.max(Number(expiresInSeconds || 0) - 60, 0) * 1000);
}
export function isAccessTokenExpired(marketplace) {
const { accessToken, accessTokenExpiresAt } = marketplace.config || {};
if (!accessToken || !accessTokenExpiresAt) {
return true;
}
return new Date(accessTokenExpiresAt).getTime() <= Date.now();
}
export function getRequiredAuthConfig(marketplace) {
const { clientId, clientSecret } = marketplace.config || {};
if (!clientId || !clientSecret) {
throw new Error('eBay marketplace is missing required config (clientId, clientSecret)');
}
return { clientId, clientSecret };
}
export function getBasicAuthHeader(marketplace) {
const { clientId, clientSecret } = getRequiredAuthConfig(marketplace);
return `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`;
}
export async function makeRequest({
marketplace,
method = 'GET',
path,
params = {},
body = null,
acceptableStatuses = [],
} = {}) {
const { accessToken } = marketplace.config || {};
if (!accessToken) {
throw new Error(
'eBay marketplace is not authenticated. Complete marketplace authorization first.'
);
}
const queryString = Object.entries(params)
.filter(([, value]) => value !== undefined && value !== null && value !== '')
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
.join('&');
const url = queryString
? `${getApiBaseUrl(marketplace)}${path}?${queryString}`
: `${getApiBaseUrl(marketplace)}${path}`;
const headers = {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
'Accept-Language': getAcceptLanguage(marketplace),
};
if (marketplace.config.marketplaceId) {
headers['X-EBAY-C-MARKETPLACE-ID'] = marketplace.config.marketplaceId;
}
const fetchOptions = {
method,
headers,
};
if (body && method !== 'GET') {
fetchOptions.headers['Content-Type'] = 'application/json';
fetchOptions.headers['Content-Language'] = getAcceptLanguage(marketplace);
fetchOptions.body = JSON.stringify(body);
}
logger.debug(`eBay API ${method} ${path}`);
const response = await fetch(url, fetchOptions);
if (response.status === 204) {
return null;
}
const data = await response.json();
console.log('DATA: ' + JSON.stringify(data, null, 2));
if (!response.ok && acceptableStatuses.includes(response.status)) {
return null;
}
if (!response.ok) {
const message = data.errors?.[0]?.message || data.error_description || response.statusText;
logger.error(`eBay API error: ${message}`, { status: response.status, path });
throw new Error(`eBay API error (${response.status}): ${message}`);
}
return data;
}
export { logger };

View File

@ -1,662 +0,0 @@
import crypto from 'crypto';
import config from '../../config.js';
import log4js from 'log4js';
const logger = log4js.getLogger('TikTok Shop');
logger.level = config.server.logLevel;
const BASE_URL = 'https://open-api.tiktokglobalshop.com';
const AUTH_BASE_URL = 'https://auth.tiktok-shops.com';
const API_VERSION = '202309';
const AUTHORIZED_SHOPS_PATH = `/authorization/${API_VERSION}/shops`;
function getTokenExpiryDate(unixTimestampSeconds) {
return new Date(Number(unixTimestampSeconds || 0) * 1000);
}
function isAccessTokenExpired(marketplace) {
const { accessToken, accessTokenExpiresAt } = marketplace.config || {};
if (!accessToken || !accessTokenExpiresAt) {
return true;
}
return new Date(accessTokenExpiresAt).getTime() <= Date.now();
}
function shouldIncludeShopCipher(path, method = 'GET') {
if (/^\/product\/(\d{6})\/(compliance|global_products|files\/upload|images\/upload)/.test(path)) {
return false;
}
if (method === 'POST' && /^\/product\/(\d{6})\/brands/.test(path)) {
return false;
}
if (/^\/(authorization|seller)\/(\d{6})\//.test(path)) {
return false;
}
return true;
}
function generateSignature({ path, method, params, body, appSecret }) {
const paramsToBeSigned = { ...params };
delete paramsToBeSigned.sign;
delete paramsToBeSigned.access_token;
delete paramsToBeSigned['x-tts-access-token'];
const sortedKeys = Object.keys(paramsToBeSigned).sort();
let payload = path;
for (const key of sortedKeys) {
const value = paramsToBeSigned[key];
if (!Array.isArray(value)) {
payload += `${key}${value}`;
}
}
if (method !== 'GET' && body && typeof body === 'object') {
payload += JSON.stringify(body);
}
payload = `${appSecret}${payload}${appSecret}`;
return crypto.createHmac('sha256', appSecret).update(payload).digest('hex');
}
async function requestAuth(path, params) {
const url = new URL(path, AUTH_BASE_URL);
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null && value !== '') {
url.searchParams.set(key, value);
}
}
const response = await fetch(url.toString(), { method: 'GET' });
const data = await response.json();
if (!response.ok || data.code !== 0) {
const message = data.message || response.statusText;
logger.error(`TikTok Shop auth error: ${message}`, { path, code: data.code });
throw new Error(`TikTok Shop auth error: ${message} (code: ${data.code ?? response.status})`);
}
return data.data;
}
async function makeRequest({ marketplace, method = 'GET', path, params = {}, body = null }) {
const { appKey, appSecret, accessToken, shopCipher } = marketplace.config || {};
if (!appKey || !appSecret || !accessToken) {
throw new Error(
'TikTok Shop marketplace is missing required config (appKey, appSecret, accessToken)'
);
}
const queryParams = {
...params,
app_key: appKey,
timestamp: Math.floor(Date.now() / 1000),
};
if (shopCipher && shouldIncludeShopCipher(path, method)) {
queryParams.shop_cipher = shopCipher;
}
const sign = generateSignature({
path,
method,
params: queryParams,
body,
appSecret,
});
queryParams.sign = sign;
const queryString = Object.entries(queryParams)
.filter(([, value]) => value !== undefined && value !== null && value !== '')
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
.join('&');
const url = `${BASE_URL}${path}?${queryString}`;
const fetchOptions = {
method,
headers: {
'Content-Type': 'application/json',
'x-tts-access-token': accessToken,
},
};
if (body && method !== 'GET') {
fetchOptions.body = JSON.stringify(body);
}
logger.debug(`TikTok Shop API ${method} ${path}`);
const response = await fetch(url, fetchOptions);
const data = await response.json();
if (data.code !== 0) {
logger.error(`TikTok Shop API error: ${data.message}`, { code: data.code, path });
throw new Error(`TikTok Shop API error: ${data.message} (code: ${data.code})`);
}
return data.data;
}
async function fetchAuthorizedShops(marketplace, accessToken) {
const authMarketplace = {
...marketplace,
config: {
...(marketplace.config || {}),
accessToken,
shopCipher: undefined,
},
};
const data = await makeRequest({
marketplace: authMarketplace,
method: 'GET',
path: AUTHORIZED_SHOPS_PATH,
});
if (Array.isArray(data?.shops)) {
return data.shops;
}
if (Array.isArray(data?.shop_list)) {
return data.shop_list;
}
if (Array.isArray(data)) {
return data;
}
return [];
}
function pickAuthorizedShop(marketplace, shops) {
if (!shops.length) {
return null;
}
const existingShopCipher = marketplace.config?.shopCipher;
const existingShopId = marketplace.config?.shopId;
const existingShopCode = marketplace.config?.shopCode;
return (
shops.find((shop) => shop.cipher === existingShopCipher) ||
shops.find((shop) => shop.id === existingShopId) ||
shops.find((shop) => shop.code === existingShopCode) ||
shops[0]
);
}
function getRequiredAuthConfig(marketplace) {
const { appKey, appSecret } = marketplace.config || {};
if (!appKey || !appSecret) {
throw new Error('TikTok Shop marketplace is missing required config (appKey, appSecret)');
}
return { appKey, appSecret };
}
function mapAuthConfigUpdates(marketplace, tokenData, shop) {
return {
accessToken: tokenData.access_token,
accessTokenExpiresAt: getTokenExpiryDate(tokenData.access_token_expire_in).toISOString(),
refreshToken: tokenData.refresh_token || marketplace.config.refreshToken,
refreshTokenExpiresAt: tokenData.refresh_token_expire_in
? getTokenExpiryDate(tokenData.refresh_token_expire_in).toISOString()
: marketplace.config.refreshTokenExpiresAt,
openId: tokenData.open_id || marketplace.config.openId,
grantedScopes: tokenData.granted_scopes || marketplace.config.grantedScopes || [],
sellerName: tokenData.seller_name || shop?.name || marketplace.config.sellerName,
sellerBaseRegion:
tokenData.seller_base_region || shop?.region || marketplace.config.sellerBaseRegion,
shopCipher: shop?.cipher || marketplace.config.shopCipher,
shopId: shop?.id || marketplace.config.shopId,
shopCode: shop?.code || marketplace.config.shopCode,
shopName: shop?.name || marketplace.config.shopName,
shopRegion: shop?.region || marketplace.config.shopRegion,
lastTokenRefreshAt: new Date().toISOString(),
};
}
export function createAuthorizationUrl(marketplace, { state } = {}) {
const { appKey } = getRequiredAuthConfig(marketplace);
const url = new URL('/oauth/authorize', AUTH_BASE_URL);
url.searchParams.set('app_key', appKey);
if (state) {
url.searchParams.set('state', state);
}
if (marketplace.config.redirectUri) {
url.searchParams.set('redirect_uri', marketplace.config.redirectUri);
}
return url.toString();
}
export async function exchangeAuthorizationCode(marketplace, { code }) {
const { appKey, appSecret } = getRequiredAuthConfig(marketplace);
if (!code) {
throw new Error('Missing TikTok Shop authorization code');
}
const tokenData = await requestAuth('/api/v2/token/get', {
app_key: appKey,
app_secret: appSecret,
auth_code: code,
grant_type: 'authorized_code',
});
const shops = await fetchAuthorizedShops(marketplace, tokenData.access_token);
const selectedShop = pickAuthorizedShop(marketplace, shops);
return {
configUpdates: mapAuthConfigUpdates(marketplace, tokenData, selectedShop),
marketplaceUpdates: {
connected: true,
connectedAt: new Date(),
},
data: {
shopCount: shops.length,
selectedShop,
},
};
}
export async function refreshAuth(marketplace) {
const { appKey, appSecret } = getRequiredAuthConfig(marketplace);
const { refreshToken } = marketplace.config || {};
if (!refreshToken) {
throw new Error('TikTok Shop marketplace is missing required config (refreshToken)');
}
const tokenData = await requestAuth('/api/v2/token/refresh', {
app_key: appKey,
app_secret: appSecret,
refresh_token: refreshToken,
grant_type: 'refresh_token',
});
let selectedShop = null;
if (!marketplace.config?.shopCipher) {
const shops = await fetchAuthorizedShops(marketplace, tokenData.access_token);
selectedShop = pickAuthorizedShop(marketplace, shops);
}
return {
configUpdates: mapAuthConfigUpdates(marketplace, tokenData, selectedShop),
data: {
selectedShop,
},
};
}
export async function ensureAuthenticatedMarketplace(marketplace) {
if (!isAccessTokenExpired(marketplace)) {
return { marketplace };
}
const authResult = await refreshAuth(marketplace);
return {
marketplace: {
...marketplace,
config: {
...(marketplace.config || {}),
...authResult.configUpdates,
},
},
configUpdates: authResult.configUpdates,
};
}
async function fetchAllProducts(marketplace) {
const products = [];
let pageToken = '';
do {
const body = {
page_size: 100,
};
if (pageToken) {
body.page_token = pageToken;
}
const data = await makeRequest({
marketplace,
method: 'POST',
path: `/product/${API_VERSION}/products/search`,
body,
});
if (data.products?.length) {
products.push(...data.products);
}
pageToken = data.next_page_token || '';
} while (pageToken);
return products;
}
async function fetchProductDetail(marketplace, productId) {
return makeRequest({
marketplace,
method: 'GET',
path: `/product/${API_VERSION}/products/${productId}`,
params: { return_under_review_version: false },
});
}
async function fetchAllOrders(marketplace, { startTime, endTime } = {}) {
const orders = [];
let pageToken = '';
const now = Math.floor(Date.now() / 1000);
do {
const body = {
page_size: 50,
sort_field: 'CREATE_TIME',
sort_order: 'DESC',
};
if (startTime || endTime) {
body.create_time_ge = startTime || now - 86400 * 30;
body.create_time_lt = endTime || now;
}
if (pageToken) {
body.page_token = pageToken;
}
const data = await makeRequest({
marketplace,
method: 'POST',
path: `/order/${API_VERSION}/orders/search`,
body,
});
if (data.orders?.length) {
orders.push(...data.orders);
}
pageToken = data.next_page_token || '';
} while (pageToken);
return orders;
}
async function fetchOrderDetail(marketplace, orderId) {
return makeRequest({
marketplace,
method: 'GET',
path: `/order/${API_VERSION}/orders/${orderId}`,
});
}
function mapListingToProduct(listing) {
const product = {
title: listing.title || '',
is_cod_allowed: false,
};
if (listing.description) {
product.description = listing.description;
}
if (listing.categoryId) {
product.category_id = listing.categoryId;
}
if (listing.imageUrls?.length) {
product.main_images = listing.imageUrls.map((url) => ({ uri: url }));
}
const sku = {
inventory: [{ quantity: listing.inventory ?? 0 }],
};
if (listing.price != null) {
sku.price = {
amount: String(listing.price),
currency: listing.currency || 'GBP',
};
}
if (listing.externalId) {
sku.seller_sku = listing.externalId;
}
product.skus = [sku];
return product;
}
export async function createItem(marketplace, listing) {
logger.info(`Creating TikTok Shop product: ${listing.title}`);
const productBody = mapListingToProduct(listing);
const result = await makeRequest({
marketplace,
method: 'POST',
path: `/product/${API_VERSION}/products`,
body: productBody,
});
const productId = result?.product_id || result?.id;
return {
externalId: productId,
url: '',
};
}
export async function updateItem(marketplace, listing) {
const productId = listing.externalId;
if (!productId) {
throw new Error('Listing must have an externalId to update on TikTok Shop');
}
logger.info(`Updating TikTok Shop product: ${productId}`);
const productBody = mapListingToProduct(listing);
await makeRequest({
marketplace,
method: 'PUT',
path: `/product/${API_VERSION}/products/${productId}`,
body: productBody,
});
return { externalId: productId };
}
export async function syncItems(marketplace) {
logger.info(`Syncing products from TikTok Shop marketplace: ${marketplace.name}`);
const products = await fetchAllProducts(marketplace);
const detailed = [];
for (const product of products) {
try {
const detail = await fetchProductDetail(marketplace, product.id);
detailed.push(detail);
} catch (err) {
logger.warn(`Failed to fetch detail for product ${product.id}: ${err.message}`);
}
}
logger.info(`Fetched ${detailed.length} product(s) from TikTok Shop`);
return detailed;
}
export async function syncOrders(marketplace, { startTime, endTime } = {}) {
logger.info(`Syncing orders from TikTok Shop marketplace: ${marketplace.name}`);
const orders = await fetchAllOrders(marketplace, { startTime, endTime });
const detailed = [];
for (const order of orders) {
try {
const detail = await fetchOrderDetail(marketplace, order.id);
detailed.push(detail);
} catch (err) {
logger.warn(`Failed to fetch detail for order ${order.id}: ${err.message}`);
}
}
logger.info(`Fetched ${detailed.length} order(s) from TikTok Shop`);
return detailed;
}
const ORDER_STATUS_MAP = {
UNPAID: 'draft',
ON_HOLD: 'draft',
AWAITING_SHIPMENT: 'confirmed',
PARTIALLY_SHIPPING: 'partiallyShipped',
AWAITING_COLLECTION: 'shipped',
IN_TRANSIT: 'shipped',
DELIVERED: 'delivered',
COMPLETED: 'completed',
CANCELLED: 'cancelled',
};
export function mapOrderStatus(tiktokStatus) {
return ORDER_STATUS_MAP[tiktokStatus] || 'draft';
}
export function mapOrderToSalesOrder(tiktokOrder) {
const paymentInfo = tiktokOrder.payment || {};
const totalAmount = parseFloat(paymentInfo.product_total_price || 0);
const shippingAmount = parseFloat(paymentInfo.shipping_fee || 0);
const totalTax = parseFloat(paymentInfo.tax || 0);
return {
externalId: tiktokOrder.id,
state: { type: mapOrderStatus(tiktokOrder.status) },
totalAmount,
totalAmountWithTax: totalAmount + totalTax,
shippingAmount,
shippingAmountWithTax: shippingAmount,
grandTotalAmount: parseFloat(paymentInfo.total_amount || 0),
totalTaxAmount: totalTax,
};
}
const PRODUCT_STATUS_MAP = {
DRAFT: 'draft',
PENDING: 'draft',
LIVE: 'active',
SELLER_DEACTIVATED: 'inactive',
PLATFORM_DEACTIVATED: 'suspended',
FROZEN: 'suspended',
DELETED: 'deleted',
};
export function mapProductToListing(tiktokProduct) {
const firstSku = tiktokProduct.skus?.[0];
const price = firstSku?.price?.sale_price
? parseFloat(firstSku.price.sale_price)
: firstSku?.price?.original_price
? parseFloat(firstSku.price.original_price)
: undefined;
const currency = firstSku?.price?.currency || undefined;
const totalInventory = (tiktokProduct.skus || []).reduce(
(sum, sku) => sum + (sku.inventory?.[0]?.quantity || 0),
0
);
const varients = (tiktokProduct.skus || []).map((sku) => {
const skuPrice = sku.price?.sale_price
? parseFloat(sku.price.sale_price)
: sku.price?.original_price
? parseFloat(sku.price.original_price)
: undefined;
return {
_reference: sku.id || sku.seller_sku || '',
price: skuPrice,
currency: sku.price?.currency || currency,
state: { type: PRODUCT_STATUS_MAP[tiktokProduct.status] || 'draft' },
};
});
return {
_reference: tiktokProduct.id,
title: tiktokProduct.title || '',
state: { type: PRODUCT_STATUS_MAP[tiktokProduct.status] || 'draft' },
price,
currency,
inventory: totalInventory,
url: tiktokProduct.url || '',
varients,
};
}
export function mapBuyerToClient(tiktokOrder) {
const buyer = tiktokOrder.buyer_info || {};
const address = tiktokOrder.recipient_address || {};
return {
name: buyer.name || address.name || 'Unknown',
email: buyer.email || '',
phone: buyer.phone_number || address.phone_number || '',
address: {
addressLine1: address.address_line1 || '',
addressLine2: address.address_line2 || '',
city: address.city || '',
state: address.state || '',
postcode: address.zipcode || '',
country: address.region_code || '',
},
};
}
export async function handleWebhook(marketplace, event) {
const { type, data } = event;
logger.info(`TikTok Shop webhook received: ${type} for marketplace ${marketplace.name}`);
switch (type) {
case 'ORDER_STATUS_CHANGE':
return { action: 'orderUpdate', orderId: data.order_id, status: data.order_status };
case 'ORDER_CREATION':
return { action: 'orderCreate', orderId: data.order_id };
case 'PRODUCT_STATUS_CHANGE':
return { action: 'productUpdate', productId: data.product_id, status: data.product_status };
case 'CANCELLATION_REQUEST':
return { action: 'orderCancel', orderId: data.order_id };
case 'RETURN_REQUEST':
return { action: 'returnRequest', orderId: data.order_id, returnId: data.return_id };
default:
logger.debug(`Unhandled TikTok Shop webhook type: ${type}`);
return { action: 'unknown', type };
}
}
export function canVerifyWebhookSignature(marketplace) {
return !!marketplace.config?.appSecret;
}
export function verifyWebhookSignature(marketplace, body, signature) {
const appSecret = marketplace.config?.appSecret;
if (!appSecret) {
return false;
}
const computed = crypto.createHmac('sha256', appSecret).update(body).digest('hex');
try {
return crypto.timingSafeEqual(Buffer.from(computed), Buffer.from(signature));
} catch {
return false;
}
}

View File

@ -1,591 +0,0 @@
import config from '../config.js';
import log4js from 'log4js';
import { clientModel } from '../database/schemas/sales/client.schema.js';
import { salesOrderModel } from '../database/schemas/sales/salesorder.schema.js';
import { listingModel } from '../database/schemas/sales/listing.schema.js';
import { listingVarientModel } from '../database/schemas/sales/listingvarient.schema.js';
import { marketplaceModel } from '../database/schemas/sales/marketplace.schema.js';
import { editObject, newObject } from '../database/database.js';
import * as tiktokShop from './marketplaces/tiktokShop.js';
import * as ebay from './marketplaces/ebay/index.js';
const logger = log4js.getLogger('Marketplace Worker');
logger.level = config.server.logLevel;
const providers = {
tiktokShop,
ebay,
};
function getProvider(marketplace) {
const provider = providers[marketplace.provider];
if (!provider) {
throw new Error(`No integration available for provider: ${marketplace.provider}`);
}
return provider;
}
export function hasIntegration(provider) {
return !!providers[provider];
}
export async function publishMarketplaceOfferForSku(marketplace, user, sku, listing) {
const authenticatedMarketplace = await ensureMarketplaceAuth(marketplace, user);
const provider = getProvider(authenticatedMarketplace);
if (typeof provider.publishOfferForSku !== 'function') {
throw new Error(
`Marketplace provider "${marketplace.provider}" does not support publishing offers`
);
}
return provider.publishOfferForSku(authenticatedMarketplace, sku, listing);
}
export async function withdrawMarketplaceOfferForSku(marketplace, user, sku) {
const authenticatedMarketplace = await ensureMarketplaceAuth(marketplace, user);
const provider = getProvider(authenticatedMarketplace);
if (typeof provider.withdrawOfferForSku !== 'function') {
throw new Error(
`Marketplace provider "${marketplace.provider}" does not support withdrawing offers`
);
}
return provider.withdrawOfferForSku(authenticatedMarketplace, sku);
}
async function persistMarketplaceUpdate(marketplace, configUpdates, marketplaceUpdates, user) {
const updateData = { updatedAt: new Date() };
if (configUpdates && Object.keys(configUpdates).length > 0) {
updateData.config = {
...(marketplace.config || {}),
...configUpdates,
};
}
if (marketplaceUpdates && Object.keys(marketplaceUpdates).length > 0) {
Object.assign(updateData, marketplaceUpdates);
}
if (Object.keys(updateData).length <= 1) {
return marketplace;
}
return editObject({
model: marketplaceModel,
id: marketplace._id,
updateData,
user,
});
}
async function ensureMarketplaceAuth(marketplace, user) {
const provider = getProvider(marketplace);
if (!provider.ensureAuthenticatedMarketplace) {
return marketplace;
}
const authResult = await provider.ensureAuthenticatedMarketplace(marketplace);
if (!authResult?.configUpdates) {
return authResult?.marketplace || marketplace;
}
return persistMarketplaceUpdate(
marketplace,
authResult.configUpdates,
authResult.marketplaceUpdates,
user
);
}
export function canAuthorize(marketplace) {
const provider = getProvider(marketplace);
return (
typeof provider.createAuthorizationUrl === 'function' &&
typeof provider.exchangeAuthorizationCode === 'function'
);
}
export function getAuthorizationUrl(marketplace, { state } = {}) {
const provider = getProvider(marketplace);
if (!provider.createAuthorizationUrl) {
throw new Error(`Provider ${marketplace.provider} does not support marketplace authorization`);
}
return provider.createAuthorizationUrl(marketplace, { state });
}
export async function exchangeAuthorizationCode(marketplace, user, { code, state } = {}) {
const provider = getProvider(marketplace);
if (!provider.exchangeAuthorizationCode) {
throw new Error(`Provider ${marketplace.provider} does not support marketplace authorization`);
}
const authResult = await provider.exchangeAuthorizationCode(marketplace, { code, state });
const updatedMarketplace = await persistMarketplaceUpdate(
marketplace,
authResult?.configUpdates || {},
authResult?.marketplaceUpdates,
user
);
return {
marketplace: updatedMarketplace,
...(authResult?.data ? { data: authResult.data } : {}),
};
}
export async function refreshMarketplaceAuth(marketplace, user) {
const provider = getProvider(marketplace);
if (!provider.refreshAuth) {
throw new Error(`Provider ${marketplace.provider} does not support token refresh`);
}
const authResult = await provider.refreshAuth(marketplace);
const updatedMarketplace = await persistMarketplaceUpdate(
marketplace,
authResult?.configUpdates || {},
authResult?.marketplaceUpdates,
user
);
return {
marketplace: updatedMarketplace,
...(authResult?.data ? { data: authResult.data } : {}),
};
}
export function canVerifyWebhookSignature(marketplace) {
const provider = getProvider(marketplace);
if (typeof provider.verifyWebhookSignature !== 'function') {
return false;
}
if (typeof provider.canVerifyWebhookSignature === 'function') {
return provider.canVerifyWebhookSignature(marketplace);
}
return true;
}
export function verifyWebhookSignature(marketplace, rawBody, signature) {
const provider = getProvider(marketplace);
if (!provider.verifyWebhookSignature) {
logger.warn(`Provider ${marketplace.provider} does not support webhook signature verification`);
return true;
}
return provider.verifyWebhookSignature(marketplace, rawBody, signature);
}
export async function handleWebhook(marketplace, event) {
const provider = getProvider(marketplace);
return provider.handleWebhook(marketplace, event);
}
async function setListingState(listingId, stateType, user, message) {
const state = { type: stateType };
if (message) state.message = message;
return editObject({
model: listingModel,
id: listingId,
updateData: { state },
user,
recalculate: false,
});
}
async function setListingVarientState(varientId, stateType, user, message) {
const state = { type: stateType };
if (message) state.message = message;
return editObject({
model: listingVarientModel,
id: varientId,
updateData: { state },
user,
recalculate: false,
});
}
async function setMarketplaceState(marketplaceId, stateType, user, message) {
const state = { type: stateType };
if (message) state.message = message;
return editObject({
model: marketplaceModel,
id: marketplaceId,
updateData: { state },
user,
recalculate: false,
});
}
async function recalculateMarketplaceState(marketplace, user) {
await marketplaceModel.recalculate(marketplace, user);
}
async function fetchFullListing(listingId) {
return listingModel
.findById(listingId)
.populate(['product', 'vendor', 'stockLocation', 'marketplace'])
.lean();
}
async function fetchListingVarients(listingId) {
return listingVarientModel.find({ listing: listingId }).lean();
}
export function createListing(marketplace, user, listingData) {
const provider = getProvider(marketplace);
if (!provider.createItem) {
logger.debug(`Provider ${marketplace.provider} does not support createItem — skipping`);
return;
}
if (listingData._id) {
setListingState(listingData._id, 'syncing', user).catch((err) =>
logger.warn(`Failed to set listing syncing state: ${err.message}`)
);
}
const work = async () => {
try {
const authenticatedMarketplace = await ensureMarketplaceAuth(marketplace, user);
const fullListing = listingData._id ? await fetchFullListing(listingData._id) : listingData;
if (!fullListing) throw new Error('Listing not found');
const varients = listingData._id ? await fetchListingVarients(listingData._id) : [];
logger.info(
`Creating listing on marketplace "${marketplace.name}" (${marketplace.provider})`
);
const result = await provider.createItem(authenticatedMarketplace, fullListing, varients);
if (listingData._id) {
const updateData = { lastSyncedAt: new Date(), state: { type: 'active' } };
if (result?.url) updateData.url = result.url;
await editObject({ model: listingModel, id: listingData._id, updateData, user });
for (const varient of varients) {
await editObject({
model: listingVarientModel,
id: varient._id,
updateData: { lastSyncedAt: new Date(), state: { type: 'active' } },
user,
}).catch(() => {});
}
}
logger.info(`Background createListing complete for marketplace "${marketplace.name}"`);
} catch (err) {
logger.error(
`Background createListing failed for marketplace "${marketplace.name}": ${err.message}`
);
if (listingData._id) {
await setListingState(listingData._id, 'draft', user, err.message).catch(() => {});
}
}
};
work();
}
export function updateListing(marketplace, user, listingData) {
const provider = getProvider(marketplace);
if (!provider.updateItem) {
logger.debug(`Provider ${marketplace.provider} does not support updateItem — skipping`);
return;
}
if (listingData._id) {
setListingState(listingData._id, 'syncing', user).catch((err) =>
logger.warn(`Failed to set listing syncing state: ${err.message}`)
);
}
const work = async () => {
try {
const authenticatedMarketplace = await ensureMarketplaceAuth(marketplace, user);
const fullListing = listingData._id ? await fetchFullListing(listingData._id) : listingData;
if (!fullListing) throw new Error('Listing not found');
const varients = listingData._id ? await fetchListingVarients(listingData._id) : [];
logger.info(
`Updating listing on marketplace "${marketplace.name}" (${marketplace.provider})`
);
await provider.updateItem(authenticatedMarketplace, fullListing, varients);
if (listingData._id) {
await editObject({
model: listingModel,
id: listingData._id,
updateData: { state: { type: 'active' }, lastSyncedAt: new Date() },
user,
});
for (const varient of varients) {
await editObject({
model: listingVarientModel,
id: varient._id,
updateData: { lastSyncedAt: new Date(), state: { type: 'active' } },
user,
}).catch(() => {});
}
}
logger.info(`Background updateListing complete for marketplace "${marketplace.name}"`);
} catch (err) {
logger.error(
`Background updateListing failed for marketplace "${marketplace.name}": ${err.message}`
);
if (listingData._id) {
await setListingState(listingData._id, 'active', user, err.message).catch(() => {});
}
}
};
work();
}
export function deleteListing(marketplace, user, listingData) {
const provider = getProvider(marketplace);
if (!provider.deleteItem) {
logger.debug(`Provider ${marketplace.provider} does not support deleteItem — skipping`);
return;
}
const work = async () => {
try {
const authenticatedMarketplace = await ensureMarketplaceAuth(marketplace, user);
logger.info(
`Deleting listing from marketplace "${marketplace.name}" (${marketplace.provider})`
);
await provider.deleteItem(authenticatedMarketplace, listingData);
logger.info(`Background deleteListing complete for marketplace "${marketplace.name}"`);
} catch (err) {
logger.error(
`Background deleteListing failed for marketplace "${marketplace.name}": ${err.message}`
);
}
};
work();
}
export function syncItems(marketplace, user) {
setMarketplaceState(marketplace._id, 'syncing', user).catch((err) =>
logger.warn(`Failed to set marketplace syncing state: ${err.message}`)
);
const work = async () => {
try {
const provider = getProvider(marketplace);
const authenticatedMarketplace = await ensureMarketplaceAuth(marketplace, user);
logger.info(
`Starting item sync for marketplace "${marketplace.name}" (${marketplace.provider})`
);
const existingListings = await listingModel
.find({
marketplace: authenticatedMarketplace._id,
'state.type': { $ne: 'deleted' },
})
.lean();
for (const listing of existingListings) {
await setListingState(listing._id, 'syncing', user).catch(() => {});
}
const existingVarients = await listingVarientModel
.find({
listing: { $in: existingListings.map((l) => l._id) },
})
.lean();
for (const varient of existingVarients) {
await setListingVarientState(varient._id, 'syncing', user).catch(() => {});
}
const results = [];
for (const listing of existingListings) {
try {
const listingVarients = existingVarients.filter(
(varient) => String(varient.listing) === String(listing._id)
);
if (!listingVarients.length) {
throw new Error('Listing has no varients to sync');
}
const result = await provider.updateItem(
authenticatedMarketplace,
listing,
listingVarients
);
const listingUpdateData = {
lastSyncedAt: new Date(),
state: listing.state || { type: 'draft' },
};
if (result?.url) {
listingUpdateData.url = result.url;
}
await editObject({
model: listingModel,
id: listing._id,
updateData: listingUpdateData,
user,
});
for (const varient of listingVarients) {
await editObject({
model: listingVarientModel,
id: varient._id,
updateData: {
lastSyncedAt: new Date(),
state: varient.state || { type: 'draft' },
},
user,
}).catch(() => {});
}
results.push({ _reference: listing._reference, action: 'synced', id: listing._id });
} catch (err) {
logger.warn(`Failed to sync listing ${listing._reference}: ${err.message}`);
await setListingState(
listing._id,
listing.state?.type || 'draft',
user,
err.message
).catch(() => {});
results.push({
_reference: listing._reference,
action: 'error',
error: err.message,
});
}
}
logger.info(
`Item sync complete for marketplace ${marketplace.name}: ${results.length} processed`
);
await recalculateMarketplaceState(marketplace, user);
} catch (err) {
logger.error(
`Background syncItems failed for marketplace "${marketplace.name}": ${err.message}`
);
await recalculateMarketplaceState(marketplace, user);
}
};
work();
}
export function syncOrders(marketplace, user, { startTime, endTime } = {}) {
setMarketplaceState(marketplace._id, 'syncing', user).catch((err) =>
logger.warn(`Failed to set marketplace syncing state: ${err.message}`)
);
const work = async () => {
try {
const provider = getProvider(marketplace);
const authenticatedMarketplace = await ensureMarketplaceAuth(marketplace, user);
logger.info(
`Starting order sync for marketplace "${marketplace.name}" (${marketplace.provider})`
);
const externalOrders = await provider.syncOrders(authenticatedMarketplace, {
startTime,
endTime,
});
const results = [];
for (const externalOrder of externalOrders) {
try {
const mapped = provider.mapOrderToSalesOrder(externalOrder);
const clientData = provider.mapBuyerToClient(externalOrder);
let client = await clientModel.findOne({
name: clientData.name,
marketplace: authenticatedMarketplace._id,
});
if (!client) {
client = await newObject({
model: clientModel,
newData: {
...clientData,
marketplace: authenticatedMarketplace._id,
active: true,
},
user,
});
logger.debug(`Created client "${clientData.name}" from marketplace order`);
}
const existingOrder = await salesOrderModel.findOne({
marketplace: authenticatedMarketplace._id,
_reference: mapped.externalId,
});
if (existingOrder) {
await editObject({
model: salesOrderModel,
id: existingOrder._id,
updateData: {
state: mapped.state,
totalAmount: mapped.totalAmount,
totalAmountWithTax: mapped.totalAmountWithTax,
shippingAmount: mapped.shippingAmount,
shippingAmountWithTax: mapped.shippingAmountWithTax,
grandTotalAmount: mapped.grandTotalAmount,
totalTaxAmount: mapped.totalTaxAmount,
updatedAt: new Date(),
},
user,
});
results.push({
externalId: mapped.externalId,
action: 'updated',
id: existingOrder._id,
});
} else {
const salesOrder = await newObject({
model: salesOrderModel,
newData: {
_reference: mapped.externalId,
client: client._id,
marketplace: authenticatedMarketplace._id,
state: mapped.state,
totalAmount: mapped.totalAmount,
totalAmountWithTax: mapped.totalAmountWithTax,
shippingAmount: mapped.shippingAmount,
shippingAmountWithTax: mapped.shippingAmountWithTax,
grandTotalAmount: mapped.grandTotalAmount,
totalTaxAmount: mapped.totalTaxAmount,
},
user,
});
results.push({ externalId: mapped.externalId, action: 'created', id: salesOrder._id });
}
} catch (err) {
logger.warn(`Failed to process order: ${err.message}`);
results.push({ externalId: externalOrder.id, action: 'error', error: err.message });
}
}
logger.info(
`Order sync complete for marketplace ${marketplace.name}: ${results.length} processed`
);
await recalculateMarketplaceState(marketplace, user);
} catch (err) {
logger.error(
`Background syncOrders failed for marketplace "${marketplace.name}": ${err.message}`
);
await recalculateMarketplaceState(marketplace, user);
}
};
work();
}

View File

@ -1,22 +1,23 @@
/**
* Authentication middleware - uses Redis session store.
* Keycloak is used only for login/refresh; session validation is done via Redis.
*/
import Keycloak from 'keycloak-connect';
import session from 'express-session';
import config, { getEnvironment } from './config.js';
import axios from 'axios';
import dotenv from 'dotenv';
import log4js from 'log4js';
import NodeCache from 'node-cache';
import bcrypt from 'bcrypt';
import { userModel } from './database/schemas/management/user.schema.js';
import { appPasswordModel } from './database/schemas/management/apppassword.schema.js';
import { getObject } from './database/database.js';
import { hostModel } from './database/schemas/management/host.schema.js';
import { getSession, lookupUserByToken } from './services/misc/auth.js';
const logger = log4js.getLogger('Keycloak');
logger.level = config.server.logLevel || 'info';
const userCache = new NodeCache({ stdTTL: 300 });
dotenv.config();
// Initialize NodeCache with 5-minute TTL
const userCache = new NodeCache({ stdTTL: 300 }); // 300 seconds = 5 minutes
// Cache event listeners for monitoring
userCache.on('expired', (key, value) => {
logger.debug(`Cache entry expired: ${key}`);
});
@ -25,18 +26,22 @@ userCache.on('flush', () => {
logger.info('Cache flushed');
});
// User lookup function with caching
const lookupUser = async (preferredUsername) => {
try {
// Check cache first
const cachedUser = userCache.get(preferredUsername);
if (cachedUser) {
logger.debug(`User found in cache: ${preferredUsername}`);
return cachedUser;
}
// If not in cache, query database
logger.debug(`User not in cache, querying database: ${preferredUsername}`);
const user = await userModel.findOne({ username: preferredUsername });
if (user) {
// Store in cache
userCache.set(preferredUsername, user);
logger.debug(`User stored in cache: ${preferredUsername}`);
return user;
@ -50,109 +55,121 @@ const lookupUser = async (preferredUsername) => {
}
};
/**
* Middleware to check if the user is authenticated.
* Supports: 1) Bearer token (Redis session), 2) Bearer token (email-render JWT for Puppeteer),
* 3) x-host-id + x-auth-code (host auth)
*/
// Initialize Keycloak
const keycloakConfig = {
realm: config.auth.keycloak.realm,
'auth-server-url': config.auth.keycloak.url,
'ssl-required': getEnvironment() === 'production' ? 'external' : 'none',
resource: config.auth.keycloak.clientId,
'confidential-port': 0,
'bearer-only': true,
'public-client': false,
'use-resource-role-mappings': true,
'verify-token-audience': true,
credentials: {
secret: process.env.KEYCLOAK_CLIENT_SECRET,
},
};
const memoryStore = new session.MemoryStore();
var expressSession = session({
secret: process.env.SESSION_SECRET || 'n00Dl3s23!',
resave: false,
saveUninitialized: true, // Set this to true to ensure session is initialized
store: memoryStore,
cookie: {
maxAge: 1800000, // 30 minutes
},
});
var keycloak = new Keycloak({ store: memoryStore }, keycloakConfig);
// Custom middleware to check if the user is authenticated
const isAuthenticated = async (req, res, next) => {
let token = null;
const authHeader = req.headers.authorization || req.headers.Authorization;
if (authHeader && authHeader.startsWith('Bearer ')) {
const token = authHeader.substring(7);
token = authHeader.substring(7);
try {
const session = await getSession(token);
if (session && session.expiresAt > Date.now()) {
req.user = session.user;
req.session = session;
return next();
// Verify token with Keycloak introspection endpoint
const response = await axios.post(
`${config.auth.keycloak.url}/realms/${config.auth.keycloak.realm}/protocol/openid-connect/token/introspect`,
new URLSearchParams({
token: token,
client_id: config.auth.keycloak.clientId,
client_secret: process.env.KEYCLOAK_CLIENT_SECRET,
}),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
}
);
const introspection = response.data;
if (!introspection.active) {
logger.info('Token is not active');
logger.debug('Token:', token);
return res.status(401).json({ error: 'Session Inactive', code: 'UNAUTHORIZED' });
}
// Try email-render JWT (short-lived token for Puppeteer email notifications)
const user = await lookupUserByToken(token);
if (user) {
req.user = user;
req.session = { user };
return next();
}
} catch (error) {
logger.error('Session lookup error:', error.message);
logger.error('Token verification error:', error.message);
return res.status(401).json({ error: 'Verification Error', code: 'UNAUTHORIZED' });
}
}
console.log('Checking host authentication', req.headers);
const hostId = req.headers['x-host-id'];
const authCode = req.headers['x-auth-code'];
if (hostId && authCode) {
const host = await getObject({ model: hostModel, id: hostId });
if (host && host.authCode === authCode) {
if (host && host.authCode == authCode) {
return next();
}
} else {
return res.status(401).json({ error: 'Not Authenticated', code: 'UNAUTHORIZED' });
}
// Fallback to session-based authentication
console.log('Using session token');
if (req.session && req.session['keycloak-token']) {
const sessionToken = req.session['keycloak-token'];
if (sessionToken.expires_at > new Date().getTime()) {
return next();
}
}
logger.debug('Not authenticated', { hostId, authCode }, 'req.headers', req.headers);
return res.status(401).json({ error: 'Not Authenticated', code: 'UNAUTHORIZED' });
};
const authenticateWithAppPassword = async (username, secret) => {
if (!username || !secret) return null;
// Helper function to extract roles from token
function extractRoles(token) {
const roles = [];
const user = await userModel.findOne({ username });
if (!user) return null;
// Extract realm roles
if (token.realm_access && token.realm_access.roles) {
roles.push(...token.realm_access.roles);
}
const appPasswords = await appPasswordModel
.find({ user: user._id, active: true })
.select('+secret')
.lean();
for (const appPassword of appPasswords) {
const storedHash = appPassword.secret;
if (storedHash && (await bcrypt.compare(secret, storedHash))) {
return user;
// Extract client roles
if (token.resource_access) {
for (const client in token.resource_access) {
if (token.resource_access[client].roles) {
roles.push(...token.resource_access[client].roles.map((role) => `${client}:${role}`));
}
}
}
return null;
};
const isAppAuthenticated = async (req, res, next) => {
const authHeader = req.headers.authorization || req.headers.Authorization;
// Supports HTTP Basic Auth (username + app password secret)
if (authHeader?.startsWith('Basic ')) {
try {
logger.debug('Basic auth header present');
const base64Credentials = authHeader.substring(6);
const credentials = Buffer.from(base64Credentials, 'base64').toString('utf-8');
const colonIndex = credentials.indexOf(':');
const username = credentials.substring(0, colonIndex).trim();
const secret = credentials.substring(colonIndex + 1).trim();
const user = await authenticateWithAppPassword(username, secret);
if (user) {
req.user = user;
req.session = { user };
return next();
}
} catch (error) {
logger.error('Basic auth error:', error.message);
}
}
return res.status(401).json({ error: 'Not Authenticated', code: 'UNAUTHORIZED' });
};
const isAppQueryAuthenticated = async (req, res, next) => {
try {
const username = typeof req.query.u === 'string' ? req.query.u.trim() : '';
const secret = typeof req.query.p === 'string' ? req.query.p : '';
const user = await authenticateWithAppPassword(username, secret);
if (user) {
req.user = user;
req.session = { user };
return next();
}
} catch (error) {
logger.error('Query auth error:', error.message);
}
return res.status(401).json({ error: 'Not Authenticated', code: 'UNAUTHORIZED' });
};
return roles;
}
// Cache management utility functions
const clearUserCache = () => {
userCache.flushAll();
logger.info('User cache cleared');
@ -168,12 +185,11 @@ const removeUserFromCache = (username) => {
};
export {
keycloak,
expressSession,
isAuthenticated,
isAppAuthenticated,
isAppQueryAuthenticated,
lookupUser,
clearUserCache,
getUserCacheStats,
removeUserFromCache,
getEnvironment,
};

View File

@ -1,151 +0,0 @@
/**
* Worker thread for sending email notifications asynchronously.
* Receives payloads from the main thread and performs Puppeteer render + nodemailer send.
*/
import { parentPort } from 'worker_threads';
import puppeteer from 'puppeteer';
import nodemailer from 'nodemailer';
import log4js from 'log4js';
import config from './config.js';
const baseUrl = (urlClient) => (urlClient || 'http://localhost:3000').replace(/\/$/, '');
async function fetchAndInlineStyles(html, urlClient) {
const base = baseUrl(urlClient);
const linkMatches = [...html.matchAll(/<link[^>]+>/g)];
const stylesheetLinks = linkMatches
.map((m) => {
const tag = m[0];
if (!/rel=["']stylesheet["']/i.test(tag)) return null;
const hrefMatch = tag.match(/href=["']([^"']+)["']/);
return hrefMatch ? { tag, href: hrefMatch[1] } : null;
})
.filter(Boolean);
let inlined = html;
for (const { tag, href } of stylesheetLinks) {
const url = href.startsWith('http') ? href : `${base}${href.startsWith('/') ? '' : '/'}${href}`;
try {
const res = await fetch(url);
if (res.ok) {
const css = await res.text();
inlined = inlined.replace(tag, `<style>${css}</style>`);
}
} catch (e) {
logger.trace('Could not fetch stylesheet:', url, e.message);
}
}
return inlined;
}
const logger = log4js.getLogger('MailWorker');
logger.level = config.server.logLevel;
async function sendEmail(payload) {
const {
email,
title,
message,
type,
metadata,
smtpConfig,
urlClient,
createdAt,
updatedAt,
authCode,
} = payload;
if (!email || !smtpConfig?.host) {
logger.warn('Missing email or SMTP config, skipping...');
return;
}
const params = new URLSearchParams({
title: title || '',
message: message || '',
type: type || 'info',
email: email || '',
createdAt: createdAt || new Date(),
updatedAt: updatedAt || new Date(),
authCode: authCode || '',
metadata: JSON.stringify(metadata || {}),
});
const templateUrl = `${baseUrl(urlClient)}/email/notification?${params.toString()}`;
logger.debug('Rendering template...');
logger.trace('Template URL:', templateUrl);
let html = '';
let browser;
try {
browser = await puppeteer.launch({
headless: 'new',
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-features=SameSiteByDefaultCookies',
],
});
const page = await browser.newPage();
page.on('console', (msg) => {
const text = msg.text();
const type = msg.type();
logger.trace(`Puppeteer [${type}]: ${text}`);
});
await page.goto(templateUrl, { waitUntil: 'networkidle0', timeout: 30000 });
await page.waitForSelector('#email-notification-root[data-rendered="true"]', { timeout: 5000 });
// Wait for Ant Design CSS-in-JS to finish injecting styles
logger.debug('Waiting for 1.5 seconds for page to render...');
await new Promise((r) => setTimeout(r, 1500));
html = await page.evaluate(() => {
const root = document.getElementById('email-notification-root');
if (!root) return document.documentElement.outerHTML;
const origin = document.location.origin;
const styleTags = Array.from(document.querySelectorAll('style'))
.map((s) => s.outerHTML)
.join('\n');
const linkTags = Array.from(document.querySelectorAll('link[rel="stylesheet"]'))
.map((link) => {
const href = link.getAttribute('href');
const abs = href?.startsWith('http')
? href
: `${origin}${href?.startsWith('/') ? '' : '/'}${href || ''}`;
return `<link rel="stylesheet" href="${abs}">`;
})
.join('\n');
return `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">${styleTags}${linkTags}</head><body>${root.outerHTML}</body></html>`;
});
html = await fetchAndInlineStyles(html, urlClient);
} catch (err) {
logger.error('MailWorker: Puppeteer error', err.message);
html = `<div style="font-family:sans-serif;padding:20px"><h2>${title || 'Notification'}</h2><p>${message || ''}</p></div>`;
} finally {
if (browser) await browser.close();
}
const transporter = nodemailer.createTransport({
host: smtpConfig.host,
port: smtpConfig.port || 587,
secure: smtpConfig.secure || false,
auth: smtpConfig.auth?.user ? smtpConfig.auth : undefined,
});
const subject = title ? `${title} - FarmControl` : 'FarmControl Notification';
const mailOptions = {
from: smtpConfig.from || 'FarmControl <noreply@tombutcher.work>',
to: email,
subject: subject,
html,
};
logger.debug('Sending email...');
logger.trace('Mail options:', mailOptions);
const info = await transporter.sendMail(mailOptions);
logger.debug('Email sent successfully.');
}
parentPort.on('message', (payload) => {
sendEmail(payload).catch((err) => {
logger.error('MailWorker: send failed', err.message);
});
});

View File

@ -1,101 +0,0 @@
import express from 'express';
import { isAuthenticated } from '../../keycloak.js';
import { getFilter, convertPropertiesString } from '../../utils.js';
const router = express.Router();
import {
listInvoicesRouteHandler,
getInvoiceRouteHandler,
editInvoiceRouteHandler,
editMultipleInvoicesRouteHandler,
newInvoiceRouteHandler,
deleteInvoiceRouteHandler,
listInvoicesByPropertiesRouteHandler,
getInvoiceStatsRouteHandler,
getInvoiceHistoryRouteHandler,
acknowledgeInvoiceRouteHandler,
cancelInvoiceRouteHandler,
postInvoiceRouteHandler,
} from '../../services/finance/invoices.js';
// list of invoices
router.get('/', isAuthenticated, (req, res) => {
const { page, limit, property, search, sort, order } = req.query;
const allowedFilters = [
'vendor',
'client',
'state',
'vendor._id',
'client._id',
'order',
'order._id',
'orderType',
];
const filter = getFilter(req.query, allowedFilters);
listInvoicesRouteHandler(req, res, page, limit, property, filter, search, sort, order);
});
router.get('/properties', isAuthenticated, (req, res) => {
let properties = convertPropertiesString(req.query.properties);
const allowedFilters = [
'vendor',
'client',
'orderType',
'order',
'state.type',
'value',
'vendor._id',
'client._id',
];
const filter = getFilter(req.query, allowedFilters, false);
var masterFilter = {};
if (req.query.masterFilter) {
masterFilter = JSON.parse(req.query.masterFilter);
}
listInvoicesByPropertiesRouteHandler(req, res, properties, filter, masterFilter);
});
router.post('/', isAuthenticated, (req, res) => {
newInvoiceRouteHandler(req, res);
});
// get invoice stats
router.get('/stats', isAuthenticated, (req, res) => {
getInvoiceStatsRouteHandler(req, res);
});
// get invoice history
router.get('/history', isAuthenticated, (req, res) => {
getInvoiceHistoryRouteHandler(req, res);
});
router.get('/:id', isAuthenticated, (req, res) => {
getInvoiceRouteHandler(req, res);
});
// update multiple invoices
router.put('/', isAuthenticated, async (req, res) => {
editMultipleInvoicesRouteHandler(req, res);
});
router.put('/:id', isAuthenticated, async (req, res) => {
editInvoiceRouteHandler(req, res);
});
router.delete('/:id', isAuthenticated, async (req, res) => {
deleteInvoiceRouteHandler(req, res);
});
router.post('/:id/post', isAuthenticated, async (req, res) => {
postInvoiceRouteHandler(req, res);
});
router.post('/:id/acknowledge', isAuthenticated, async (req, res) => {
acknowledgeInvoiceRouteHandler(req, res);
});
router.post('/:id/cancel', isAuthenticated, async (req, res) => {
cancelInvoiceRouteHandler(req, res);
});
export default router;

View File

@ -1,106 +0,0 @@
import express from 'express';
import { isAuthenticated } from '../../keycloak.js';
import { getFilter, convertPropertiesString } from '../../utils.js';
const router = express.Router();
import {
listPaymentsRouteHandler,
getPaymentRouteHandler,
editPaymentRouteHandler,
editMultiplePaymentsRouteHandler,
newPaymentRouteHandler,
deletePaymentRouteHandler,
listPaymentsByPropertiesRouteHandler,
getPaymentStatsRouteHandler,
getPaymentHistoryRouteHandler,
postPaymentRouteHandler,
authorisePaymentRouteHandler,
declinePaymentRouteHandler,
cancelPaymentRouteHandler,
} from '../../services/finance/payments.js';
// list of payments
router.get('/', isAuthenticated, (req, res) => {
const { page, limit, property, search, sort, order } = req.query;
const allowedFilters = [
'vendor',
'client',
'state',
'vendor._id',
'client._id',
'invoice',
'invoice._id',
];
const filter = getFilter(req.query, allowedFilters);
listPaymentsRouteHandler(req, res, page, limit, property, filter, search, sort, order);
});
router.get('/properties', isAuthenticated, (req, res) => {
let properties = convertPropertiesString(req.query.properties);
const allowedFilters = [
'vendor',
'client',
'invoice',
'state.type',
'value',
'vendor._id',
'client._id',
'invoice._id',
];
const filter = getFilter(req.query, allowedFilters, false);
var masterFilter = {};
if (req.query.masterFilter) {
masterFilter = JSON.parse(req.query.masterFilter);
}
listPaymentsByPropertiesRouteHandler(req, res, properties, filter, masterFilter);
});
router.post('/', isAuthenticated, (req, res) => {
newPaymentRouteHandler(req, res);
});
// get payment stats
router.get('/stats', isAuthenticated, (req, res) => {
getPaymentStatsRouteHandler(req, res);
});
// get payment history
router.get('/history', isAuthenticated, (req, res) => {
getPaymentHistoryRouteHandler(req, res);
});
router.get('/:id', isAuthenticated, (req, res) => {
getPaymentRouteHandler(req, res);
});
// update multiple payments
router.put('/', isAuthenticated, async (req, res) => {
editMultiplePaymentsRouteHandler(req, res);
});
router.put('/:id', isAuthenticated, async (req, res) => {
editPaymentRouteHandler(req, res);
});
router.delete('/:id', isAuthenticated, async (req, res) => {
deletePaymentRouteHandler(req, res);
});
router.post('/:id/post', isAuthenticated, async (req, res) => {
postPaymentRouteHandler(req, res);
});
router.post('/:id/authorise', isAuthenticated, async (req, res) => {
authorisePaymentRouteHandler(req, res);
});
router.post('/:id/decline', isAuthenticated, async (req, res) => {
declinePaymentRouteHandler(req, res);
});
router.post('/:id/cancel', isAuthenticated, async (req, res) => {
cancelPaymentRouteHandler(req, res);
});
export default router;

View File

@ -1,5 +1,4 @@
import userRoutes from './management/users.js';
import appPasswordRoutes from './management/apppasswords.js';
import fileRoutes from './management/files.js';
import authRoutes from './misc/auth.js';
import printerRoutes from './production/printers.js';
@ -8,25 +7,18 @@ import jobRoutes from './production/jobs.js';
import subJobRoutes from './production/subjobs.js';
import gcodeFileRoutes from './production/gcodefiles.js';
import filamentRoutes from './management/filaments.js';
import filamentSkuRoutes from './management/filamentskus.js';
import spotlightRoutes from './misc/spotlight.js';
import partRoutes from './management/parts.js';
import partSkuRoutes from './management/partskus.js';
import productRoutes from './management/products.js';
import productCategoryRoutes from './management/productcategories.js';
import productSkuRoutes from './management/productskus.js';
import vendorRoutes from './management/vendors.js';
import materialRoutes from './management/materials.js';
import partStockRoutes from './inventory/partstocks.js';
import productStockRoutes from './inventory/productstocks.js';
import filamentStockRoutes from './inventory/filamentstocks.js';
import purchaseOrderRoutes from './inventory/purchaseorders.js';
import orderItemRoutes from './inventory/orderitems.js';
import shipmentRoutes from './inventory/shipments.js';
import stockEventRoutes from './inventory/stockevents.js';
import stockAuditRoutes from './inventory/stockaudits.js';
import stockLocationRoutes from './inventory/stocklocations.js';
import stockTransferRoutes from './inventory/stocktransfers.js';
import auditLogRoutes from './management/auditlogs.js';
import noteTypeRoutes from './management/notetypes.js';
import documentSizesRoutes from './management/documentsizes.js';
@ -36,28 +28,11 @@ import documentJobsRoutes from './management/documentjobs.js';
import courierRoutes from './management/courier.js';
import courierServiceRoutes from './management/courierservice.js';
import taxRateRoutes from './management/taxrates.js';
import taxRecordRoutes from './finance/taxrecords.js';
import invoiceRoutes from './finance/invoices.js';
import paymentRoutes from './finance/payments.js';
import clientRoutes from './sales/clients.js';
import salesOrderRoutes from './sales/salesorders.js';
import marketplaceRoutes from './sales/marketplaces.js';
import listingRoutes from './sales/listings.js';
import listingVarientRoutes from './sales/listingvarients.js';
import taxRecordRoutes from './management/taxrecords.js';
import noteRoutes from './misc/notes.js';
import userNotifierRoutes from './misc/usernotifiers.js';
import notificationRoutes from './misc/notifications.js';
import odataRoutes from './misc/odata.js';
import rssRoutes from './misc/rss.js';
import excelRoutes from './misc/excel.js';
import csvRoutes from './misc/csv.js';
import appLaunchRoutes from './misc/applaunch.js';
import appUpdateRoutes from './misc/appupdate.js';
import serverRoutes from './misc/server.js';
export {
userRoutes,
appPasswordRoutes,
fileRoutes,
authRoutes,
printerRoutes,
@ -66,25 +41,18 @@ export {
subJobRoutes,
gcodeFileRoutes,
filamentRoutes,
filamentSkuRoutes,
spotlightRoutes,
partRoutes,
partSkuRoutes,
productRoutes,
productCategoryRoutes,
productSkuRoutes,
vendorRoutes,
materialRoutes,
partStockRoutes,
productStockRoutes,
filamentStockRoutes,
purchaseOrderRoutes,
orderItemRoutes,
shipmentRoutes,
stockEventRoutes,
stockAuditRoutes,
stockLocationRoutes,
stockTransferRoutes,
auditLogRoutes,
noteTypeRoutes,
noteRoutes,
@ -96,20 +64,4 @@ export {
courierServiceRoutes,
taxRateRoutes,
taxRecordRoutes,
invoiceRoutes,
paymentRoutes,
clientRoutes,
salesOrderRoutes,
marketplaceRoutes,
listingRoutes,
listingVarientRoutes,
userNotifierRoutes,
notificationRoutes,
odataRoutes,
rssRoutes,
excelRoutes,
csvRoutes,
appLaunchRoutes,
appUpdateRoutes,
serverRoutes,
};

View File

@ -7,7 +7,6 @@ import {
listFilamentStocksRouteHandler,
getFilamentStockRouteHandler,
editFilamentStockRouteHandler,
editMultipleFilamentStocksRouteHandler,
newFilamentStockRouteHandler,
deleteFilamentStockRouteHandler,
listFilamentStocksByPropertiesRouteHandler,
@ -18,30 +17,14 @@ import {
// list of filament stocks
router.get('/', isAuthenticated, (req, res) => {
const { page, limit, property, search, sort, order } = req.query;
const allowedFilters = [
'filament',
'filament._id',
'filamentSku',
'state',
'startingWeight',
'currentWeight',
'filamentSku._id',
'stockLocation',
'stockLocation._id',
];
const allowedFilters = ['filament', 'state', 'startingWeight', 'currentWeight', 'filament._id'];
const filter = getFilter(req.query, allowedFilters);
listFilamentStocksRouteHandler(req, res, page, limit, property, filter, search, sort, order);
});
router.get('/properties', isAuthenticated, (req, res) => {
let properties = convertPropertiesString(req.query.properties);
const allowedFilters = [
'filament',
'filament._id',
'filamentSku',
'state.type',
'filamentSku._id',
];
const allowedFilters = ['filament', 'state.type'];
const filter = getFilter(req.query, allowedFilters, false);
var masterFilter = {};
if (req.query.masterFilter) {
@ -68,11 +51,6 @@ router.get('/:id', isAuthenticated, (req, res) => {
getFilamentStockRouteHandler(req, res);
});
// update multiple filament stocks
router.put('/', isAuthenticated, async (req, res) => {
editMultipleFilamentStocksRouteHandler(req, res);
});
router.put('/:id', isAuthenticated, async (req, res) => {
editFilamentStockRouteHandler(req, res);
});

View File

@ -7,7 +7,6 @@ import {
listOrderItemsRouteHandler,
getOrderItemRouteHandler,
editOrderItemRouteHandler,
editMultipleOrderItemsRouteHandler,
newOrderItemRouteHandler,
deleteOrderItemRouteHandler,
listOrderItemsByPropertiesRouteHandler,
@ -18,34 +17,14 @@ import {
// list of order items
router.get('/', isAuthenticated, (req, res) => {
const { page, limit, property, search, sort, order } = req.query;
const allowedFilters = [
'name',
'itemType',
'item',
'item._id',
'order',
'order._id',
'orderType',
'shipment',
'shipment._id',
];
const allowedFilters = ['itemType', 'item', 'item._id', 'order', 'order._id', 'orderType'];
const filter = getFilter(req.query, allowedFilters);
listOrderItemsRouteHandler(req, res, page, limit, property, filter, search, sort, order);
});
router.get('/properties', isAuthenticated, (req, res) => {
let properties = convertPropertiesString(req.query.properties);
const allowedFilters = [
'name',
'itemType',
'item',
'item._id',
'order',
'order._id',
'orderType',
'shipment',
'shipment._id',
];
const allowedFilters = ['itemType', 'item', 'item._id', 'order', 'order._id', 'orderType'];
const filter = getFilter(req.query, allowedFilters, false);
var masterFilter = {};
if (req.query.masterFilter) {
@ -72,11 +51,6 @@ router.get('/:id', isAuthenticated, (req, res) => {
getOrderItemRouteHandler(req, res);
});
// update multiple order items
router.put('/', isAuthenticated, async (req, res) => {
editMultipleOrderItemsRouteHandler(req, res);
});
router.put('/:id', isAuthenticated, async (req, res) => {
editOrderItemRouteHandler(req, res);
});

View File

@ -7,7 +7,6 @@ import {
listPartStocksRouteHandler,
getPartStockRouteHandler,
editPartStockRouteHandler,
editMultiplePartStocksRouteHandler,
newPartStockRouteHandler,
deletePartStockRouteHandler,
listPartStocksByPropertiesRouteHandler,
@ -18,15 +17,7 @@ import {
// list of part stocks
router.get('/', isAuthenticated, (req, res) => {
const { page, limit, property, search, sort, order } = req.query;
const allowedFilters = [
'partSku',
'state',
'startingQuantity',
'currentQuantity',
'partSku._id',
'stockLocation',
'stockLocation._id',
];
const allowedFilters = ['part', 'state', 'startingQuantity', 'currentQuantity', 'part._id'];
const filter = getFilter(req.query, allowedFilters);
listPartStocksRouteHandler(req, res, page, limit, property, filter, search, sort, order);
});
@ -60,11 +51,6 @@ router.get('/:id', isAuthenticated, (req, res) => {
getPartStockRouteHandler(req, res);
});
// update multiple part stocks
router.put('/', isAuthenticated, async (req, res) => {
editMultiplePartStocksRouteHandler(req, res);
});
router.put('/:id', isAuthenticated, async (req, res) => {
editPartStockRouteHandler(req, res);
});

View File

@ -1,76 +0,0 @@
import express from 'express';
import { isAuthenticated } from '../../keycloak.js';
import { getFilter, convertPropertiesString } from '../../utils.js';
const router = express.Router();
import {
listProductStocksRouteHandler,
getProductStockRouteHandler,
editProductStockRouteHandler,
editMultipleProductStocksRouteHandler,
newProductStockRouteHandler,
deleteProductStockRouteHandler,
postProductStockRouteHandler,
listProductStocksByPropertiesRouteHandler,
getProductStockStatsRouteHandler,
getProductStockHistoryRouteHandler,
} from '../../services/inventory/productstocks.js';
router.get('/', isAuthenticated, (req, res) => {
const { page, limit, property, search, sort, order } = req.query;
const allowedFilters = [
'productSku',
'state',
'currentQuantity',
'productSku._id',
'stockLocation',
'stockLocation._id',
];
const filter = getFilter(req.query, allowedFilters);
listProductStocksRouteHandler(req, res, page, limit, property, filter, search, sort, order);
});
router.get('/properties', isAuthenticated, (req, res) => {
let properties = convertPropertiesString(req.query.properties);
const allowedFilters = ['productSku', 'state.type'];
const filter = getFilter(req.query, allowedFilters, false);
var masterFilter = {};
if (req.query.masterFilter) {
masterFilter = JSON.parse(req.query.masterFilter);
}
listProductStocksByPropertiesRouteHandler(req, res, properties, filter, masterFilter);
});
router.post('/', isAuthenticated, (req, res) => {
newProductStockRouteHandler(req, res);
});
router.get('/stats', isAuthenticated, (req, res) => {
getProductStockStatsRouteHandler(req, res);
});
router.get('/history', isAuthenticated, (req, res) => {
getProductStockHistoryRouteHandler(req, res);
});
router.get('/:id', isAuthenticated, (req, res) => {
getProductStockRouteHandler(req, res);
});
router.put('/', isAuthenticated, async (req, res) => {
editMultipleProductStocksRouteHandler(req, res);
});
router.put('/:id', isAuthenticated, async (req, res) => {
editProductStockRouteHandler(req, res);
});
router.delete('/:id', isAuthenticated, async (req, res) => {
deleteProductStockRouteHandler(req, res);
});
router.post('/:id/post', isAuthenticated, async (req, res) => {
postProductStockRouteHandler(req, res);
});
export default router;

View File

@ -7,54 +7,28 @@ import {
listPurchaseOrdersRouteHandler,
getPurchaseOrderRouteHandler,
editPurchaseOrderRouteHandler,
editMultiplePurchaseOrdersRouteHandler,
newPurchaseOrderRouteHandler,
deletePurchaseOrderRouteHandler,
listPurchaseOrdersByPropertiesRouteHandler,
getPurchaseOrderStatsRouteHandler,
getPurchaseOrderHistoryRouteHandler,
postPurchaseOrderRouteHandler,
acknowledgePurchaseOrderRouteHandler,
cancelPurchaseOrderRouteHandler,
} from '../../services/inventory/purchaseorders.js';
// list of purchase orders
router.get('/', isAuthenticated, (req, res) => {
const { page, limit, property, search, sort, order } = req.query;
const allowedFilters = [
'vendor',
'state',
'value',
'vendor._id',
'totalAmount',
'totalAmountWithTax',
'totalTaxAmount',
'shippingAmount',
'shippingAmountWithTax',
'grandTotalAmount',
];
const allowedFilters = ['vendor', 'state', 'value', 'vendor._id'];
const filter = getFilter(req.query, allowedFilters);
listPurchaseOrdersRouteHandler(req, res, page, limit, property, filter, search, sort, order);
});
router.get('/properties', isAuthenticated, (req, res) => {
let properties = convertPropertiesString(req.query.properties);
const allowedFilters = [
'vendor',
'state.type',
'value',
'vendor._id',
'totalAmount',
'totalAmountWithTax',
'totalTaxAmount',
'shippingAmount',
'shippingAmountWithTax',
'grandTotalAmount',
];
const allowedFilters = ['vendor', 'state.type', 'value', 'vendor._id'];
const filter = getFilter(req.query, allowedFilters, false);
var masterFilter = {};
if (req.query.masterFilter) {
masterFilter = getFilter(JSON.parse(req.query.masterFilter), allowedFilters, true);
masterFilter = JSON.parse(req.query.masterFilter);
}
listPurchaseOrdersByPropertiesRouteHandler(req, res, properties, filter, masterFilter);
});
@ -77,11 +51,6 @@ router.get('/:id', isAuthenticated, (req, res) => {
getPurchaseOrderRouteHandler(req, res);
});
// update multiple purchase orders
router.put('/', isAuthenticated, async (req, res) => {
editMultiplePurchaseOrdersRouteHandler(req, res);
});
router.put('/:id', isAuthenticated, async (req, res) => {
editPurchaseOrderRouteHandler(req, res);
});
@ -90,16 +59,4 @@ router.delete('/:id', isAuthenticated, async (req, res) => {
deletePurchaseOrderRouteHandler(req, res);
});
router.post('/:id/post', isAuthenticated, async (req, res) => {
postPurchaseOrderRouteHandler(req, res);
});
router.post('/:id/acknowledge', isAuthenticated, async (req, res) => {
acknowledgePurchaseOrderRouteHandler(req, res);
});
router.post('/:id/cancel', isAuthenticated, async (req, res) => {
cancelPurchaseOrderRouteHandler(req, res);
});
export default router;

View File

@ -7,21 +7,24 @@ import {
listShipmentsRouteHandler,
getShipmentRouteHandler,
editShipmentRouteHandler,
editMultipleShipmentsRouteHandler,
newShipmentRouteHandler,
deleteShipmentRouteHandler,
listShipmentsByPropertiesRouteHandler,
getShipmentStatsRouteHandler,
getShipmentHistoryRouteHandler,
shipShipmentRouteHandler,
receiveShipmentRouteHandler,
cancelShipmentRouteHandler,
} from '../../services/inventory/shipments.js';
// list of shipments
router.get('/', isAuthenticated, (req, res) => {
const { page, limit, property, search, sort, order } = req.query;
const allowedFilters = ['orderType', 'order', 'state', 'courierService', 'order._id', 'taxRate'];
const allowedFilters = [
'vendor',
'purchaseOrder',
'state',
'courierService',
'vendor._id',
'purchaseOrder._id',
];
const filter = getFilter(req.query, allowedFilters);
listShipmentsRouteHandler(req, res, page, limit, property, filter, search, sort, order);
});
@ -29,17 +32,17 @@ router.get('/', isAuthenticated, (req, res) => {
router.get('/properties', isAuthenticated, (req, res) => {
let properties = convertPropertiesString(req.query.properties);
const allowedFilters = [
'orderType',
'order',
'vendor',
'purchaseOrder',
'state.type',
'courierService',
'order._id',
'taxRate',
'vendor._id',
'purchaseOrder._id',
];
const filter = getFilter(req.query, allowedFilters, false);
var masterFilter = {};
if (req.query.masterFilter) {
masterFilter = getFilter(JSON.parse(req.query.masterFilter), allowedFilters, true);
masterFilter = JSON.parse(req.query.masterFilter);
}
listShipmentsByPropertiesRouteHandler(req, res, properties, filter, masterFilter);
});
@ -62,11 +65,6 @@ router.get('/:id', isAuthenticated, (req, res) => {
getShipmentRouteHandler(req, res);
});
// update multiple shipments
router.put('/', isAuthenticated, async (req, res) => {
editMultipleShipmentsRouteHandler(req, res);
});
router.put('/:id', isAuthenticated, async (req, res) => {
editShipmentRouteHandler(req, res);
});
@ -75,16 +73,4 @@ router.delete('/:id', isAuthenticated, async (req, res) => {
deleteShipmentRouteHandler(req, res);
});
router.post('/:id/ship', isAuthenticated, async (req, res) => {
shipShipmentRouteHandler(req, res);
});
router.post('/:id/receive', isAuthenticated, async (req, res) => {
receiveShipmentRouteHandler(req, res);
});
router.post('/:id/cancel', isAuthenticated, async (req, res) => {
cancelShipmentRouteHandler(req, res);
});
export default router;

View File

@ -8,7 +8,6 @@ import {
getStockEventRouteHandler,
newStockEventRouteHandler,
editStockEventRouteHandler,
editMultipleStockEventsRouteHandler,
deleteStockEventRouteHandler,
listStockEventsByPropertiesRouteHandler,
getStockEventStatsRouteHandler,
@ -52,11 +51,6 @@ router.get('/:id', isAuthenticated, (req, res) => {
getStockEventRouteHandler(req, res);
});
// update multiple stock events
router.put('/', isAuthenticated, async (req, res) => {
editMultipleStockEventsRouteHandler(req, res);
});
router.put('/:id', isAuthenticated, async (req, res) => {
editStockEventRouteHandler(req, res);
});

View File

@ -1,64 +0,0 @@
import express from 'express';
import { isAuthenticated } from '../../keycloak.js';
import { getFilter, convertPropertiesString } from '../../utils.js';
const router = express.Router();
import {
listStockLocationsRouteHandler,
getStockLocationRouteHandler,
editStockLocationRouteHandler,
editMultipleStockLocationsRouteHandler,
newStockLocationRouteHandler,
deleteStockLocationRouteHandler,
listStockLocationsByPropertiesRouteHandler,
getStockLocationStatsRouteHandler,
getStockLocationHistoryRouteHandler,
} from '../../services/inventory/stocklocations.js';
router.get('/', isAuthenticated, (req, res) => {
const { page, limit, property, search, sort, order } = req.query;
const allowedFilters = ['name'];
const filter = getFilter(req.query, allowedFilters);
listStockLocationsRouteHandler(req, res, page, limit, property, filter, search, sort, order);
});
router.get('/properties', isAuthenticated, (req, res) => {
let properties = convertPropertiesString(req.query.properties);
const allowedFilters = ['name'];
const filter = getFilter(req.query, allowedFilters, false);
var masterFilter = {};
if (req.query.masterFilter) {
masterFilter = JSON.parse(req.query.masterFilter);
}
listStockLocationsByPropertiesRouteHandler(req, res, properties, filter, masterFilter);
});
router.post('/', isAuthenticated, (req, res) => {
newStockLocationRouteHandler(req, res);
});
router.get('/stats', isAuthenticated, (req, res) => {
getStockLocationStatsRouteHandler(req, res);
});
router.get('/history', isAuthenticated, (req, res) => {
getStockLocationHistoryRouteHandler(req, res);
});
router.get('/:id', isAuthenticated, (req, res) => {
getStockLocationRouteHandler(req, res);
});
router.put('/', isAuthenticated, async (req, res) => {
editMultipleStockLocationsRouteHandler(req, res);
});
router.put('/:id', isAuthenticated, async (req, res) => {
editStockLocationRouteHandler(req, res);
});
router.delete('/:id', isAuthenticated, async (req, res) => {
deleteStockLocationRouteHandler(req, res);
});
export default router;

View File

@ -1,69 +0,0 @@
import express from 'express';
import { isAuthenticated } from '../../keycloak.js';
import { getFilter, convertPropertiesString } from '../../utils.js';
const router = express.Router();
import {
listStockTransfersRouteHandler,
getStockTransferRouteHandler,
editStockTransferRouteHandler,
editMultipleStockTransfersRouteHandler,
newStockTransferRouteHandler,
deleteStockTransferRouteHandler,
postStockTransferRouteHandler,
listStockTransfersByPropertiesRouteHandler,
getStockTransferStatsRouteHandler,
getStockTransferHistoryRouteHandler,
} from '../../services/inventory/stocktransfers.js';
router.get('/', isAuthenticated, (req, res) => {
const { page, limit, property, search, sort, order } = req.query;
const allowedFilters = ['state', 'state.type', 'postedAt'];
const filter = getFilter(req.query, allowedFilters);
listStockTransfersRouteHandler(req, res, page, limit, property, filter, search, sort, order);
});
router.get('/properties', isAuthenticated, (req, res) => {
let properties = convertPropertiesString(req.query.properties);
const allowedFilters = ['state.type'];
const filter = getFilter(req.query, allowedFilters, false);
var masterFilter = {};
if (req.query.masterFilter) {
masterFilter = JSON.parse(req.query.masterFilter);
}
listStockTransfersByPropertiesRouteHandler(req, res, properties, filter, masterFilter);
});
router.post('/', isAuthenticated, (req, res) => {
newStockTransferRouteHandler(req, res);
});
router.get('/stats', isAuthenticated, (req, res) => {
getStockTransferStatsRouteHandler(req, res);
});
router.get('/history', isAuthenticated, (req, res) => {
getStockTransferHistoryRouteHandler(req, res);
});
router.get('/:id', isAuthenticated, (req, res) => {
getStockTransferRouteHandler(req, res);
});
router.put('/', isAuthenticated, async (req, res) => {
editMultipleStockTransfersRouteHandler(req, res);
});
router.put('/:id', isAuthenticated, async (req, res) => {
editStockTransferRouteHandler(req, res);
});
router.delete('/:id', isAuthenticated, async (req, res) => {
deleteStockTransferRouteHandler(req, res);
});
router.post('/:id/post', isAuthenticated, async (req, res) => {
postStockTransferRouteHandler(req, res);
});
export default router;

View File

@ -1,61 +0,0 @@
import express from 'express';
import { isAuthenticated } from '../../keycloak.js';
import { getFilter, convertPropertiesString } from '../../utils.js';
const router = express.Router();
import {
listAppPasswordsRouteHandler,
getAppPasswordRouteHandler,
editAppPasswordRouteHandler,
newAppPasswordRouteHandler,
deleteAppPasswordRouteHandler,
listAppPasswordsByPropertiesRouteHandler,
getAppPasswordStatsRouteHandler,
getAppPasswordHistoryRouteHandler,
regenerateSecretRouteHandler,
} from '../../services/management/apppasswords.js';
router.get('/', isAuthenticated, (req, res) => {
const { page, limit, property, search, sort, order } = req.query;
const allowedFilters = ['_id', 'name', 'user', 'active', 'user._id'];
const filter = getFilter(req.query, allowedFilters);
listAppPasswordsRouteHandler(req, res, page, limit, property, filter, search, sort, order);
});
router.get('/properties', isAuthenticated, (req, res) => {
const properties = convertPropertiesString(req.query.properties);
const allowedFilters = ['name', 'user', 'active'];
const filter = getFilter(req.query, allowedFilters, false);
const masterFilter = req.query.masterFilter ? JSON.parse(req.query.masterFilter) : {};
listAppPasswordsByPropertiesRouteHandler(req, res, properties, filter, masterFilter);
});
router.post('/', isAuthenticated, (req, res) => {
newAppPasswordRouteHandler(req, res);
});
router.get('/stats', isAuthenticated, (req, res) => {
getAppPasswordStatsRouteHandler(req, res);
});
router.get('/history', isAuthenticated, (req, res) => {
getAppPasswordHistoryRouteHandler(req, res);
});
router.post('/:id/regenerateSecret', isAuthenticated, async (req, res) => {
regenerateSecretRouteHandler(req, res);
});
router.get('/:id', isAuthenticated, (req, res) => {
getAppPasswordRouteHandler(req, res);
});
router.put('/:id', isAuthenticated, async (req, res) => {
editAppPasswordRouteHandler(req, res);
});
router.delete('/:id', isAuthenticated, async (req, res) => {
deleteAppPasswordRouteHandler(req, res);
});
export default router;

View File

@ -8,9 +8,7 @@ import {
listFilamentsByPropertiesRouteHandler,
getFilamentRouteHandler,
editFilamentRouteHandler,
editMultipleFilamentsRouteHandler,
newFilamentRouteHandler,
deleteFilamentRouteHandler,
getFilamentStatsRouteHandler,
getFilamentHistoryRouteHandler,
} from '../../services/management/filaments.js';
@ -21,10 +19,12 @@ router.get('/', isAuthenticated, (req, res) => {
const allowedFilters = [
'_id',
'material',
'material._id',
'type',
'vendor.name',
'diameter',
'color',
'name',
'vendor._id',
'cost',
];
@ -43,7 +43,7 @@ router.get('/', isAuthenticated, (req, res) => {
router.get('/properties', isAuthenticated, (req, res) => {
let properties = convertPropertiesString(req.query.properties);
const allowedFilters = ['diameter', 'material'];
const allowedFilters = ['diameter', 'type', 'vendor'];
const filter = getFilter(req.query, allowedFilters, false);
listFilamentsByPropertiesRouteHandler(req, res, properties, filter);
});
@ -66,18 +66,9 @@ router.get('/:id', isAuthenticated, (req, res) => {
getFilamentRouteHandler(req, res);
});
// update filaments info
router.put('/', isAuthenticated, async (req, res) => {
editMultipleFilamentsRouteHandler(req, res);
});
// update filament info
// update printer info
router.put('/:id', isAuthenticated, async (req, res) => {
editFilamentRouteHandler(req, res);
});
router.delete('/:id', isAuthenticated, async (req, res) => {
deleteFilamentRouteHandler(req, res);
});
export default router;

View File

@ -1,59 +0,0 @@
import express from 'express';
import { isAuthenticated } from '../../keycloak.js';
import { getFilter, convertPropertiesString } from '../../utils.js';
const router = express.Router();
import {
listFilamentSkusRouteHandler,
getFilamentSkuRouteHandler,
editFilamentSkuRouteHandler,
newFilamentSkuRouteHandler,
deleteFilamentSkuRouteHandler,
listFilamentSkusByPropertiesRouteHandler,
getFilamentSkuStatsRouteHandler,
getFilamentSkuHistoryRouteHandler,
} from '../../services/management/filamentskus.js';
router.get('/', isAuthenticated, (req, res) => {
const { page, limit, property, search, sort, order } = req.query;
const allowedFilters = ['_id', 'barcode', 'filament', 'filament._id', 'name', 'color', 'cost', 'costWithTax'];
const filter = getFilter(req.query, allowedFilters);
listFilamentSkusRouteHandler(req, res, page, limit, property, filter, search, sort, order);
});
router.get('/properties', isAuthenticated, (req, res) => {
let properties = convertPropertiesString(req.query.properties);
const allowedFilters = ['filament', 'filament._id'];
const filter = getFilter(req.query, allowedFilters, false);
let masterFilter = {};
if (req.query.masterFilter) {
masterFilter = JSON.parse(req.query.masterFilter);
}
listFilamentSkusByPropertiesRouteHandler(req, res, properties, filter, masterFilter);
});
router.post('/', isAuthenticated, (req, res) => {
newFilamentSkuRouteHandler(req, res);
});
router.get('/stats', isAuthenticated, (req, res) => {
getFilamentSkuStatsRouteHandler(req, res);
});
router.get('/history', isAuthenticated, (req, res) => {
getFilamentSkuHistoryRouteHandler(req, res);
});
router.get('/:id', isAuthenticated, (req, res) => {
getFilamentSkuRouteHandler(req, res);
});
router.put('/:id', isAuthenticated, async (req, res) => {
editFilamentSkuRouteHandler(req, res);
});
router.delete('/:id', isAuthenticated, async (req, res) => {
deleteFilamentSkuRouteHandler(req, res);
});
export default router;

View File

@ -1,11 +1,10 @@
import express from 'express';
import { isAuthenticated } from '../../keycloak.js';
import { convertPropertiesString, getFilter, parseFilter } from '../../utils.js';
import { parseFilter } from '../../utils.js';
const router = express.Router();
import {
listMaterialsRouteHandler,
listMaterialsByPropertiesRouteHandler,
getMaterialRouteHandler,
editMaterialRouteHandler,
newMaterialRouteHandler,
@ -15,26 +14,22 @@ import {
// list of materials
router.get('/', isAuthenticated, (req, res) => {
const { page, limit, property, search, sort, order } = req.query;
const { page, limit, property } = req.query;
const allowedFilters = ['_id', 'name', 'tags'];
const allowedFilters = ['type', 'brand', 'diameter', 'color'];
var filter = {};
for (const [key, value] of Object.entries(req.query)) {
if (allowedFilters.includes(key)) {
filter = { ...filter, ...parseFilter(key, value) };
for (var i = 0; i < allowedFilters.length; i++) {
if (key == allowedFilters[i]) {
const parsedFilter = parseFilter(key, value);
filter = { ...filter, ...parsedFilter };
}
}
}
listMaterialsRouteHandler(req, res, page, limit, property, filter, search, sort, order);
});
router.get('/properties', isAuthenticated, (req, res) => {
let properties = convertPropertiesString(req.query.properties);
const allowedFilters = ['name', 'tags'];
const filter = getFilter(req.query, allowedFilters, false);
listMaterialsByPropertiesRouteHandler(req, res, properties, filter);
listMaterialsRouteHandler(req, res, page, limit, property, filter);
});
router.post('/', isAuthenticated, (req, res) => {
@ -55,7 +50,7 @@ router.get('/:id', isAuthenticated, (req, res) => {
getMaterialRouteHandler(req, res);
});
// update material info
// update printer info
router.put('/:id', isAuthenticated, async (req, res) => {
editMaterialRouteHandler(req, res);
});

View File

@ -1,59 +0,0 @@
import express from 'express';
import { isAuthenticated } from '../../keycloak.js';
import { getFilter, convertPropertiesString } from '../../utils.js';
const router = express.Router();
import {
listPartSkusRouteHandler,
getPartSkuRouteHandler,
editPartSkuRouteHandler,
newPartSkuRouteHandler,
deletePartSkuRouteHandler,
listPartSkusByPropertiesRouteHandler,
getPartSkuStatsRouteHandler,
getPartSkuHistoryRouteHandler,
} from '../../services/management/partskus.js';
router.get('/', isAuthenticated, (req, res) => {
const { page, limit, property, search, sort, order } = req.query;
const allowedFilters = ['_id', 'barcode', 'part', 'part._id', 'name', 'cost', 'price'];
const filter = getFilter(req.query, allowedFilters);
listPartSkusRouteHandler(req, res, page, limit, property, filter, search, sort, order);
});
router.get('/properties', isAuthenticated, (req, res) => {
let properties = convertPropertiesString(req.query.properties);
const allowedFilters = ['part', 'part._id'];
const filter = getFilter(req.query, allowedFilters, false);
let masterFilter = {};
if (req.query.masterFilter) {
masterFilter = JSON.parse(req.query.masterFilter);
}
listPartSkusByPropertiesRouteHandler(req, res, properties, filter, masterFilter);
});
router.post('/', isAuthenticated, (req, res) => {
newPartSkuRouteHandler(req, res);
});
router.get('/stats', isAuthenticated, (req, res) => {
getPartSkuStatsRouteHandler(req, res);
});
router.get('/history', isAuthenticated, (req, res) => {
getPartSkuHistoryRouteHandler(req, res);
});
router.get('/:id', isAuthenticated, (req, res) => {
getPartSkuRouteHandler(req, res);
});
router.put('/:id', isAuthenticated, async (req, res) => {
editPartSkuRouteHandler(req, res);
});
router.delete('/:id', isAuthenticated, async (req, res) => {
deletePartSkuRouteHandler(req, res);
});
export default router;

View File

@ -1,55 +0,0 @@
import express from 'express';
import { isAuthenticated } from '../../keycloak.js';
import { convertPropertiesString, getFilter } from '../../utils.js';
import {
deleteProductCategoryRouteHandler,
editProductCategoryRouteHandler,
getProductCategoryHistoryRouteHandler,
getProductCategoryRouteHandler,
getProductCategoryStatsRouteHandler,
listProductCategoriesByPropertiesRouteHandler,
listProductCategoriesRouteHandler,
newProductCategoryRouteHandler,
} from '../../services/management/productcategories.js';
const router = express.Router();
router.get('/', isAuthenticated, (req, res) => {
const { page, limit, property, search, sort, order } = req.query;
const allowedFilters = ['_id', 'name'];
const filter = getFilter(req.query, allowedFilters);
listProductCategoriesRouteHandler(req, res, page, limit, property, filter, search, sort, order);
});
router.get('/properties', isAuthenticated, (req, res) => {
const properties = convertPropertiesString(req.query.properties);
const allowedFilters = ['name'];
const filter = getFilter(req.query, allowedFilters, false);
listProductCategoriesByPropertiesRouteHandler(req, res, properties, filter);
});
router.post('/', isAuthenticated, (req, res) => {
newProductCategoryRouteHandler(req, res);
});
router.get('/stats', isAuthenticated, (req, res) => {
getProductCategoryStatsRouteHandler(req, res);
});
router.get('/history', isAuthenticated, (req, res) => {
getProductCategoryHistoryRouteHandler(req, res);
});
router.get('/:id', isAuthenticated, (req, res) => {
getProductCategoryRouteHandler(req, res);
});
router.put('/:id', isAuthenticated, async (req, res) => {
editProductCategoryRouteHandler(req, res);
});
router.delete('/:id', isAuthenticated, async (req, res) => {
deleteProductCategoryRouteHandler(req, res);
});
export default router;

View File

@ -17,7 +17,7 @@ import {
// list of products
router.get('/', isAuthenticated, (req, res) => {
const { page, limit, property, search, sort, order } = req.query;
const allowedFilters = ['_id', 'name', 'globalPrice', 'productCategory', 'productCategory._id'];
const allowedFilters = ['_id', 'name', 'globalPrice'];
const filter = getFilter(req.query, allowedFilters);
listProductsRouteHandler(req, res, page, limit, property, filter, search, sort, order);
});

View File

@ -1,59 +0,0 @@
import express from 'express';
import { isAuthenticated } from '../../keycloak.js';
import { getFilter, convertPropertiesString } from '../../utils.js';
const router = express.Router();
import {
listProductSkusRouteHandler,
getProductSkuRouteHandler,
editProductSkuRouteHandler,
newProductSkuRouteHandler,
deleteProductSkuRouteHandler,
listProductSkusByPropertiesRouteHandler,
getProductSkuStatsRouteHandler,
getProductSkuHistoryRouteHandler,
} from '../../services/management/productskus.js';
router.get('/', isAuthenticated, (req, res) => {
const { page, limit, property, search, sort, order } = req.query;
const allowedFilters = ['_id', 'barcode', 'product', 'product._id', 'name', 'cost', 'price'];
const filter = getFilter(req.query, allowedFilters);
listProductSkusRouteHandler(req, res, page, limit, property, filter, search, sort, order);
});
router.get('/properties', isAuthenticated, (req, res) => {
let properties = convertPropertiesString(req.query.properties);
const allowedFilters = ['product', 'product._id'];
const filter = getFilter(req.query, allowedFilters, false);
let masterFilter = {};
if (req.query.masterFilter) {
masterFilter = JSON.parse(req.query.masterFilter);
}
listProductSkusByPropertiesRouteHandler(req, res, properties, filter, masterFilter);
});
router.post('/', isAuthenticated, (req, res) => {
newProductSkuRouteHandler(req, res);
});
router.get('/stats', isAuthenticated, (req, res) => {
getProductSkuStatsRouteHandler(req, res);
});
router.get('/history', isAuthenticated, (req, res) => {
getProductSkuHistoryRouteHandler(req, res);
});
router.get('/:id', isAuthenticated, (req, res) => {
getProductSkuRouteHandler(req, res);
});
router.put('/:id', isAuthenticated, async (req, res) => {
editProductSkuRouteHandler(req, res);
});
router.delete('/:id', isAuthenticated, async (req, res) => {
deleteProductSkuRouteHandler(req, res);
});
export default router;

View File

@ -12,7 +12,7 @@ import {
listTaxRecordsByPropertiesRouteHandler,
getTaxRecordStatsRouteHandler,
getTaxRecordHistoryRouteHandler,
} from '../../services/finance/taxrecords.js';
} from '../../services/management/taxrecords.js';
// list of tax records
router.get('/', isAuthenticated, (req, res) => {

View File

@ -10,7 +10,6 @@ import {
editUserRouteHandler,
getUserStatsRouteHandler,
getUserHistoryRouteHandler,
setAppPasswordRouteHandler,
} from '../../services/management/users.js';
// list of document templates
@ -51,8 +50,4 @@ router.put('/:id', isAuthenticated, async (req, res) => {
editUserRouteHandler(req, res);
});
router.post('/:id/setAppPassword', isAuthenticated, async (req, res) => {
setAppPasswordRouteHandler(req, res);
});
export default router;

View File

@ -1,14 +0,0 @@
import express from 'express';
import {
appLaunchSessionRouteHandler,
completeAppLaunchSessionRouteHandler,
} from '../../services/misc/applaunch.js';
import { isAuthenticated } from '../../keycloak.js';
const router = express.Router();
router.get('/:launchSession', appLaunchSessionRouteHandler);
router.post('/:launchSession', isAuthenticated, completeAppLaunchSessionRouteHandler);
export default router;

View File

@ -1,13 +0,0 @@
import express from 'express';
import { isAuthenticated } from '../../keycloak.js';
import {
appUpdateBranchesRouteHandler,
appUpdateCurrentRouteHandler,
} from '../../services/misc/appupdate.js';
const router = express.Router();
router.get('/branches', isAuthenticated, appUpdateBranchesRouteHandler);
router.get('/current', isAuthenticated, appUpdateCurrentRouteHandler);
export default router;

View File

@ -1,9 +0,0 @@
import express from 'express';
import { isAuthenticated } from '../../keycloak.js';
import { csvExportRouteHandler } from '../../services/misc/csv.js';
const router = express.Router();
router.get('/:objectType', isAuthenticated, csvExportRouteHandler);
export default router;

View File

@ -1,16 +0,0 @@
import express from 'express';
import { isAuthenticated } from '../../keycloak.js';
import {
excelExportRouteHandler,
excelOpenRouteHandler,
excelTempRouteHandler,
} from '../../services/misc/excel.js';
const router = express.Router();
// Temp route must come before /:objectType so "temp" is not matched as objectType
router.get('/temp/:token', excelTempRouteHandler);
router.post('/:objectType/open', isAuthenticated, excelOpenRouteHandler);
router.get('/:objectType', isAuthenticated, excelExportRouteHandler);
export default router;

View File

@ -1,37 +0,0 @@
import express from 'express';
import { isAuthenticated } from '../../keycloak.js';
import {
listNotificationsRouteHandler,
markNotificationAsReadRouteHandler,
markAllNotificationsAsReadRouteHandler,
deleteNotificationRouteHandler,
deleteAllNotificationsRouteHandler,
} from '../../services/misc/notifications.js';
import { getFilter } from '../../utils.js';
const router = express.Router();
router.get('/', isAuthenticated, (req, res) => {
const { page = 1, limit = 50, sort = 'createdAt', order = 'descend' } = req.query;
const allowedFilters = ['user'];
const filter = getFilter(req.query, allowedFilters);
listNotificationsRouteHandler(req, res, page, limit, filter, sort, order);
});
router.put('/read-all', isAuthenticated, (req, res) => {
markAllNotificationsAsReadRouteHandler(req, res);
});
router.put('/:id/read', isAuthenticated, (req, res) => {
markNotificationAsReadRouteHandler(req, res);
});
router.delete('/', isAuthenticated, (req, res) => {
deleteAllNotificationsRouteHandler(req, res);
});
router.delete('/:id', isAuthenticated, (req, res) => {
deleteNotificationRouteHandler(req, res);
});
export default router;

View File

@ -1,10 +0,0 @@
import express from 'express';
import { isAppAuthenticated } from '../../keycloak.js';
import { listODataRouteHandler, metadataODataRouteHandler } from '../../services/misc/odata.js';
const router = express.Router();
router.get('/$metadata', isAppAuthenticated, metadataODataRouteHandler);
router.get('/:objectType', isAppAuthenticated, listODataRouteHandler);
export default router;

View File

@ -1,9 +0,0 @@
import express from 'express';
import { isAppQueryAuthenticated } from '../../keycloak.js';
import { listRssRouteHandler } from '../../services/misc/rss.js';
const router = express.Router();
router.get('/:objectType', isAppQueryAuthenticated, listRssRouteHandler);
export default router;

View File

@ -1,9 +0,0 @@
import express from 'express';
import { isAuthenticated } from '../../keycloak.js';
import { serverVersionRouteHandler } from '../../services/misc/server.js';
const router = express.Router();
router.get('/version', isAuthenticated, serverVersionRouteHandler);
export default router;

View File

@ -1,37 +0,0 @@
import express from 'express';
import { isAuthenticated } from '../../keycloak.js';
import {
listUserNotifiersRouteHandler,
getUserNotifierRouteHandler,
newUserNotifierRouteHandler,
deleteUserNotifierRouteHandler,
editUserNotifierRouteHandler,
} from '../../services/misc/usernotifiers.js';
import { getFilter } from '../../utils.js';
const router = express.Router();
router.get('/', isAuthenticated, (req, res) => {
const { page, limit, property, search, sort, order } = req.query;
const allowedFilters = ['user', 'object', 'objectType'];
const filter = getFilter(req.query, allowedFilters);
listUserNotifiersRouteHandler(req, res, page, limit, property, filter, search, sort, order);
});
router.post('/', isAuthenticated, (req, res) => {
newUserNotifierRouteHandler(req, res);
});
router.get('/:id', isAuthenticated, (req, res) => {
getUserNotifierRouteHandler(req, res);
});
router.put('/:id', isAuthenticated, (req, res) => {
editUserNotifierRouteHandler(req, res);
});
router.delete('/:id', isAuthenticated, (req, res) => {
deleteUserNotifierRouteHandler(req, res);
});
export default router;

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