Added printer and filament profile schemas, routes, and associated logic for managing profiles. Enhanced authentication for slicer uploads and integrated new routes for handling slicer-related operations. Updated database utilities and services to support new features, improving overall functionality and user experience.
Some checks failed
farmcontrol/farmcontrol-api/pipeline/head There was a failure building this commit

This commit is contained in:
Tom Butcher 2026-07-20 02:11:44 +01:00
parent e5981ce419
commit cb16385b33
28 changed files with 2127 additions and 50 deletions

View File

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

View File

@ -556,6 +556,15 @@ export const listObjectsByProperties = async ({
// Build aggregation pipeline
const pipeline = [];
logger.debug('Master filter:', masterFilter);
// Match before populate so reference fields (e.g. filament) are still ObjectIds
if (Object.keys(masterFilter).length > 0) {
const convertedFilter = convertObjectIdStringsInFilter(masterFilter);
logger.debug('Converted filter:', convertedFilter);
pipeline.push({ $match: convertedFilter });
}
// Handle populate (array or single value)
if (populate) {
const populates = Array.isArray(populate) ? populate : [populate];
@ -599,14 +608,6 @@ 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 (propertiesPresent) {
// Build the $group _id object for all properties
const groupId = {};

View File

@ -55,6 +55,25 @@ class RedisServer {
await this.client.del(key);
}
async getAndDeleteKey(key) {
await this.connect();
const value = await this.client.getDel(key);
if (value == null) return null;
try {
return JSON.parse(value);
} catch {
return value;
}
}
async eval(script, keys = [], args = []) {
await this.connect();
return this.client.eval(script, {
keys,
arguments: args.map((arg) => String(arg)),
});
}
async getKeysByPattern(pattern) {
await this.connect();
const keys = [];

View File

@ -1,6 +1,8 @@
import { jobModel } from './production/job.schema.js';
import { subJobModel } from './production/subjob.schema.js';
import { printerModel } from './production/printer.schema.js';
import { printerProfileModel } from './production/printerprofile.schema.js';
import { filamentProfileModel } from './production/filamentprofile.schema.js';
import { filamentModel } from './management/filament.schema.js';
import { filamentSkuModel } from './management/filamentsku.schema.js';
import { gcodeFileModel } from './production/gcodefile.schema.js';
@ -55,6 +57,20 @@ export const models = {
referenceField: '_reference',
label: 'Printer',
},
PPF: {
model: printerProfileModel,
idField: '_id',
type: 'printerProfile',
referenceField: '_reference',
label: 'Printer Profile',
},
FPF: {
model: filamentProfileModel,
idField: '_id',
type: 'filamentProfile',
referenceField: '_reference',
label: 'Filament Profile',
},
FIL: {
model: filamentModel,
idField: '_id',

View File

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

View File

@ -25,6 +25,18 @@ const alertSchema = new Schema(
{ timestamps: true, _id: false }
);
const pendingSlicerUploadSchema = new Schema(
{
file: { type: Schema.Types.ObjectId, ref: 'file', required: true },
gcodeFile: { type: Schema.Types.ObjectId, ref: 'gcodeFile', default: null },
job: { type: Schema.Types.ObjectId, ref: 'job', default: null },
shouldPrint: { type: Boolean, required: true, default: false },
new: { type: Boolean, required: true, default: true },
properties: { type: Object, required: true, default: () => ({}) },
},
{ timestamps: true }
);
// Define the main printer schema
const printerSchema = new Schema(
{
@ -53,6 +65,7 @@ const printerSchema = new Schema(
vendor: { type: Schema.Types.ObjectId, ref: 'vendor', default: null },
host: { type: Schema.Types.ObjectId, ref: 'host', default: null },
alerts: [alertSchema],
pendingSlicerUploads: { type: [pendingSlicerUploadSchema], default: [] },
},
{ timestamps: true }
);

View File

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

View File

@ -20,6 +20,32 @@ export function toObjectIdIfValid(value) {
return value;
}
/** Extract an ObjectId from a populated reference object, if applicable. */
function extractReferenceId(value) {
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
value instanceof mongoose.Types.ObjectId ||
value instanceof Date ||
value._id == null
) {
return null;
}
const keys = Object.keys(value);
const looksLikeReference = keys.every((k) => k === '_id' || k === '__v');
if (!looksLikeReference) {
return null;
}
const id = value._id;
if (id instanceof mongoose.Types.ObjectId) {
return id;
}
return toObjectIdIfValid(id?.toString?.() ?? id);
}
/** Recursively convert ObjectId strings to ObjectId in a filter object for MongoDB $match. */
export function convertObjectIdStringsInFilter(filter) {
if (!filter || typeof filter !== 'object') return filter;
@ -28,12 +54,20 @@ export function convertObjectIdStringsInFilter(filter) {
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));
result[key] = value.map((v) => {
const refId = extractReferenceId(v);
if (refId !== null) return refId;
return isObjectIdString(v) ? new mongoose.Types.ObjectId(v) : v;
});
} else if (value && typeof value === 'object' && !Array.isArray(value)) {
result[key] = convertObjectIdStringsInFilter(value);
} else {
result[key] = toObjectIdIfValid(value);
}
} else {
const refId = extractReferenceId(value);
if (refId !== null) {
result[key] = refId;
} else if (
value &&
typeof value === 'object' &&
@ -46,5 +80,6 @@ export function convertObjectIdStringsInFilter(filter) {
result[key] = toObjectIdIfValid(value);
}
}
}
return result;
}

View File

@ -10,6 +10,8 @@ import {
appPasswordRoutes,
fileRoutes,
printerRoutes,
printerProfileRoutes,
filamentProfileRoutes,
jobRoutes,
subJobRoutes,
gcodeFileRoutes,
@ -61,6 +63,7 @@ import {
appLaunchRoutes,
appUpdateRoutes,
serverRoutes,
slicerRoutes,
} from './routes/index.js';
import path from 'path';
import * as fs from 'fs';
@ -139,6 +142,8 @@ app.use('/apppasswords', appPasswordRoutes);
app.use('/files', fileRoutes);
app.use('/spotlight', spotlightRoutes);
app.use('/printers', printerRoutes);
app.use('/printerprofiles', printerProfileRoutes);
app.use('/filamentprofiles', filamentProfileRoutes);
app.use('/hosts', hostRoutes);
app.use('/jobs', jobRoutes);
app.use('/subjobs', subJobRoutes);
@ -189,6 +194,7 @@ app.use('/csv', csvRoutes);
app.use('/applaunch', appLaunchRoutes);
app.use('/appupdate', appUpdateRoutes);
app.use('/server', serverRoutes);
app.use('/slicer', slicerRoutes);
// Start the application
if (process.env.NODE_ENV !== 'test') {

View File

@ -95,7 +95,7 @@ const isAuthenticated = async (req, res, next) => {
const authenticateWithAppPassword = async (username, secret) => {
if (!username || !secret) return null;
const user = await userModel.findOne({ username });
const user = await userModel.findOne({ username }).lean();
if (!user) return null;
const appPasswords = await appPasswordModel
@ -115,6 +115,17 @@ const authenticateWithAppPassword = async (username, secret) => {
const isAppAuthenticated = async (req, res, next) => {
const authHeader = req.headers.authorization || req.headers.Authorization;
const apiKey = req.headers['x-api-key'];
const userParam = req.params.username;
const passwordParam = req.params.password;
logger.debug('App authentication request', {
hasBasicAuth: authHeader?.startsWith('Basic ') === true,
hasApiKey: Boolean(apiKey),
hasPasswordParam: Boolean(passwordParam),
userParam,
});
// Supports HTTP Basic Auth (username + app password secret)
if (authHeader?.startsWith('Basic ')) {
try {
@ -134,6 +145,16 @@ const isAppAuthenticated = async (req, res, next) => {
logger.error('Basic auth error:', error.message);
}
}
const appPassword = apiKey || passwordParam;
if (appPassword && userParam) {
logger.debug('App password and username present', { userParam });
const user = await authenticateWithAppPassword(userParam, appPassword);
if (user) {
req.user = user;
req.session = { user };
return next();
}
}
return res.status(401).json({ error: 'Not Authenticated', code: 'UNAUTHORIZED' });
};

View File

@ -3,6 +3,8 @@ import appPasswordRoutes from './management/apppasswords.js';
import fileRoutes from './management/files.js';
import authRoutes from './misc/auth.js';
import printerRoutes from './production/printers.js';
import printerProfileRoutes from './production/printerprofiles.js';
import filamentProfileRoutes from './production/filamentprofiles.js';
import hostRoutes from './management/hosts.js';
import jobRoutes from './production/jobs.js';
import subJobRoutes from './production/subjobs.js';
@ -54,6 +56,7 @@ import csvRoutes from './misc/csv.js';
import appLaunchRoutes from './misc/applaunch.js';
import appUpdateRoutes from './misc/appupdate.js';
import serverRoutes from './misc/server.js';
import slicerRoutes from './misc/slicer.js';
export {
userRoutes,
@ -61,6 +64,8 @@ export {
fileRoutes,
authRoutes,
printerRoutes,
printerProfileRoutes,
filamentProfileRoutes,
hostRoutes,
jobRoutes,
subJobRoutes,
@ -112,4 +117,5 @@ export {
appLaunchRoutes,
appUpdateRoutes,
serverRoutes,
slicerRoutes,
};

23
src/routes/misc/slicer.js Normal file
View File

@ -0,0 +1,23 @@
import express from 'express';
import { isAppAuthenticated } from '../../keycloak.js';
import {
slicerUploadRouteHandler,
slicerUIRouteHandler,
slicerVersionRouteHandler,
} from '../../services/misc/slicer.js';
const router = express.Router();
router.get('/:printerId/:username/:password', isAppAuthenticated, slicerUIRouteHandler);
router.get(
'/:printerId/:username/:password/api/version',
isAppAuthenticated,
slicerVersionRouteHandler
);
router.post(
'/:printerId/:username/:password/api/files/local',
isAppAuthenticated,
slicerUploadRouteHandler
);
export default router;

View File

@ -0,0 +1,48 @@
import express from 'express';
import { isAuthenticated } from '../../keycloak.js';
import { convertPropertiesString, getFilter } from '../../utils.js';
import {
deleteFilamentProfileRouteHandler,
editFilamentProfileRouteHandler,
getFilamentProfileRouteHandler,
listFilamentProfilesByPropertiesRouteHandler,
listFilamentProfilesRouteHandler,
newFilamentProfileRouteHandler,
searchFilamentProfilesRouteHandler,
} from '../../services/production/filamentprofiles.js';
const router = express.Router();
router.get('/', isAuthenticated, (req, res) => {
const { page, limit, property, search, sort, order } = req.query;
const filter = getFilter(req.query, ['_id', 'name']);
listFilamentProfilesRouteHandler(req, res, page, limit, property, filter, search, sort, order);
});
router.get('/properties', isAuthenticated, (req, res) => {
const properties = convertPropertiesString(req.query.properties);
const filter = getFilter(req.query, ['name'], false);
listFilamentProfilesByPropertiesRouteHandler(req, res, properties, filter);
});
router.get('/search', isAuthenticated, (req, res) => {
searchFilamentProfilesRouteHandler(req, res, req.query.search);
});
router.post('/', isAuthenticated, (req, res) => {
newFilamentProfileRouteHandler(req, res);
});
router.get('/:id', isAuthenticated, (req, res) => {
getFilamentProfileRouteHandler(req, res);
});
router.put('/:id', isAuthenticated, (req, res) => {
editFilamentProfileRouteHandler(req, res);
});
router.delete('/:id', isAuthenticated, (req, res) => {
deleteFilamentProfileRouteHandler(req, res);
});
export default router;

View File

@ -0,0 +1,48 @@
import express from 'express';
import { isAuthenticated } from '../../keycloak.js';
import { convertPropertiesString, getFilter } from '../../utils.js';
import {
deletePrinterProfileRouteHandler,
editPrinterProfileRouteHandler,
getPrinterProfileRouteHandler,
listPrinterProfilesByPropertiesRouteHandler,
listPrinterProfilesRouteHandler,
newPrinterProfileRouteHandler,
searchPrinterProfilesRouteHandler,
} from '../../services/production/printerprofiles.js';
const router = express.Router();
router.get('/', isAuthenticated, (req, res) => {
const { page, limit, property, search, sort, order } = req.query;
const filter = getFilter(req.query, ['_id', 'name']);
listPrinterProfilesRouteHandler(req, res, page, limit, property, filter, search, sort, order);
});
router.get('/properties', isAuthenticated, (req, res) => {
const properties = convertPropertiesString(req.query.properties);
const filter = getFilter(req.query, ['name'], false);
listPrinterProfilesByPropertiesRouteHandler(req, res, properties, filter);
});
router.get('/search', isAuthenticated, (req, res) => {
searchPrinterProfilesRouteHandler(req, res, req.query.search);
});
router.post('/', isAuthenticated, (req, res) => {
newPrinterProfileRouteHandler(req, res);
});
router.get('/:id', isAuthenticated, (req, res) => {
getPrinterProfileRouteHandler(req, res);
});
router.put('/:id', isAuthenticated, (req, res) => {
editPrinterProfileRouteHandler(req, res);
});
router.delete('/:id', isAuthenticated, (req, res) => {
deletePrinterProfileRouteHandler(req, res);
});
export default router;

View File

@ -52,16 +52,11 @@ jest.unstable_mockModule('log4js', () => ({
},
}));
const {
listFilesRouteHandler,
getFileRouteHandler,
editFileRouteHandler,
flushFileRouteHandler,
} = await import('../files.js');
const { listFilesRouteHandler, getFileRouteHandler, editFileRouteHandler, flushFileRouteHandler } =
await import('../files.js');
const { listObjects, getObject, editObject, flushFile } = await import(
'../../../database/database.js'
);
const { listObjects, getObject, editObject, flushFile } =
await import('../../../database/database.js');
const { fileModel } = await import('../../../database/schemas/management/file.schema.js');
describe('File Service Route Handlers', () => {
@ -121,4 +116,3 @@ describe('File Service Route Handlers', () => {
});
});
});

View File

@ -11,7 +11,7 @@ import {
listObjectsByProperties,
getModelStats,
getModelHistory,
searchObjects
searchObjects,
} from '../../database/database.js';
const logger = log4js.getLogger('Filament SKUs');
logger.level = config.server.logLevel;
@ -60,7 +60,7 @@ export const listFilamentSkusByPropertiesRouteHandler = async (
model: filamentSkuModel,
properties,
filter,
populate: ['costTaxRate'],
populate: ['costTaxRate', 'filament'],
masterFilter,
});

View File

@ -15,7 +15,7 @@ import {
flushFile,
getModelStats,
getModelHistory,
searchObjects
searchObjects,
} from '../../database/database.js';
import {
uploadFile,
@ -28,6 +28,17 @@ import { getFileMeta } from '../../utils.js';
const logger = log4js.getLogger('Files');
logger.level = config.server.logLevel;
const getDownloadContentType = (file) => {
// G-code is text, but it must be transferred byte-for-byte. Some slicers
// report text/plain, causing proxies to compress an otherwise fixed-length
// response and browsers to reject it with a content-length network error.
if (file.extension?.toLowerCase() === '.gcode') {
return 'application/octet-stream';
}
return file.type || 'application/octet-stream';
};
// Set storage engine to memory for Ceph upload
const fileStorage = multer.memoryStorage();
@ -288,9 +299,31 @@ function checkFileType(file, cb) {
}
export const getFileContentRouteHandler = async (req, res) => {
try {
const id = req.params.id;
let bytesSent = 0;
let sourceEnded = false;
logger.info(`File download requested: ${id}`);
req.once('aborted', () => {
logger.warn(`File download request aborted by client: ${id} (${bytesSent} bytes sent)`);
});
res.once('finish', () => {
logger.info(
`File download response finished: ${id} (status ${res.statusCode}, ${bytesSent} bytes sent)`
);
});
res.once('close', () => {
if (!res.writableFinished) {
logger.warn(
`File download response closed early: ${id} (${bytesSent} bytes sent, source ended: ${sourceEnded})`
);
}
});
try {
const file = await getObject({
model: fileModel,
id,
@ -302,27 +335,64 @@ export const getFileContentRouteHandler = async (req, res) => {
}
logger.trace(`Returning file contents with ID: ${id}:`);
logger.debug(`File record loaded for download: ${id}`, {
name: file.name,
extension: file.extension,
recordedSize: file.size,
type: file.type,
});
// Check if file is stored in Ceph
if (file._id && file.extension) {
const cephKey = `files/${id}${file.extension}`;
logger.info(`Starting Ceph download: ${cephKey} (recorded size: ${file.size ?? 'unknown'})`);
try {
const body = await downloadFile(BUCKETS.FILES, cephKey);
logger.debug(`Ceph response stream opened: ${cephKey}`, {
readable: body?.readable,
destroyed: body?.destroyed,
});
// Set appropriate content type and disposition
res.set('Content-Type', file.type || 'application/octet-stream');
// Preserve the stored bytes across reverse proxies. This also repairs
// downloads of existing slicer uploads recorded as text/plain.
res.set('Content-Type', getDownloadContentType(file));
res.set('Content-Disposition', `attachment; filename="${file.name}${file.extension}"`);
// Expose file size so clients can compute download progress
if (typeof file.size === 'number' && !Number.isNaN(file.size)) {
res.set('Content-Length', String(file.size));
}
res.set('Cache-Control', 'private, no-transform');
// Stream or send buffer
if (body && typeof body.pipe === 'function') {
body.on('data', (chunk) => {
bytesSent += chunk.length;
});
body.on('end', () => {
sourceEnded = true;
logger.info(`Ceph stream ended: ${cephKey} (${bytesSent} bytes read)`);
if (
typeof file.size === 'number' &&
!Number.isNaN(file.size) &&
bytesSent !== file.size
) {
logger.warn(
`File size mismatch for ${cephKey}: database=${file.size}, streamed=${bytesSent}`
);
}
});
body.on('close', () => {
logger.debug(
`Ceph stream closed: ${cephKey} (${bytesSent} bytes read, ended: ${sourceEnded})`
);
});
// Handle stream errors
body.on('error', (err) => {
logger.error('Error streaming file from Ceph:', err);
logger.error(
`Error streaming file from Ceph: ${cephKey} (${bytesSent} bytes read)`,
err
);
// If headers not sent, send a 500; otherwise destroy the response
if (!res.headersSent) {
try {
@ -343,7 +413,7 @@ export const getFileContentRouteHandler = async (req, res) => {
});
body.pipe(res);
logger.debug('Retrieved:', cephKey);
logger.debug(`Ceph stream piped to response: ${cephKey}`);
return;
}
@ -357,6 +427,7 @@ export const getFileContentRouteHandler = async (req, res) => {
// Fallback to local file system for backward compatibility
const filePath = path.join(config.storage.fileStorage, file.fileName || file.name);
logger.info(`Falling back to local file download: ${filePath}`);
// Read the file
fs.readFile(filePath, (err, data) => {
@ -367,8 +438,13 @@ export const getFileContentRouteHandler = async (req, res) => {
return res.status(500).send({ error: 'Error reading file.' });
}
res.set('Content-Type', file.type || 'application/octet-stream');
bytesSent = data.length;
sourceEnded = true;
logger.info(`Local file read complete: ${filePath} (${data.length} bytes)`);
res.set('Content-Type', getDownloadContentType(file));
res.set('Content-Disposition', `inline; filename="${file.name}${file.extension || ''}"`);
res.set('Cache-Control', 'private, no-transform');
// Ensure Content-Length is set for progress events if possible.
const length =

View File

@ -0,0 +1,90 @@
import { jest } from '@jest/globals';
const redisMock = {
setKey: jest.fn(),
getKey: jest.fn(),
deleteKey: jest.fn(),
getAndDeleteKey: jest.fn(),
eval: jest.fn(),
};
jest.unstable_mockModule('../../../config.js', () => ({
default: { server: { logLevel: 'info' } },
}));
jest.unstable_mockModule('../../../database/redis.js', () => ({
redisServer: redisMock,
}));
jest.unstable_mockModule('log4js', () => ({
default: {
getLogger: () => ({
level: 'info',
debug: jest.fn(),
}),
},
}));
const {
createSlicerSession,
createSlicerAuthCode,
getAndConsumeSlicerAuthCode,
isSlicerAuthCode,
} = await import('../sessionStore.js');
describe('slicer sessions', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('atomically replaces the current slicer session with a 99-year session', async () => {
const now = Date.now();
const user = { _id: 'user-1', username: 'slicer-user', roles: ['user'] };
const result = await createSlicerSession(user);
expect(result.sessionToken).toMatch(/^[a-f0-9]{64}$/);
expect(result.expiresAt).toBeGreaterThan(now + 98 * 365 * 24 * 60 * 60 * 1000);
expect(result.expiresAt).toBeLessThan(now + 100 * 366 * 24 * 60 * 60 * 1000);
expect(redisMock.eval).toHaveBeenCalledWith(
expect.stringContaining("redis.call('DEL'"),
['slicer-session:user-1', `session:${result.sessionToken}`],
[
expect.stringContaining('"source":"slicer"'),
expect.any(Number),
result.sessionToken,
'session:',
]
);
});
it('creates a short-lived one-time auth code containing the session response', async () => {
const session = {
sessionToken: 'session-token',
expiresAt: Date.now() + 1000,
user: { _id: 'user-1', username: 'slicer-user' },
};
const code = await createSlicerAuthCode(session);
expect(isSlicerAuthCode(code)).toBe(true);
expect(redisMock.setKey).toHaveBeenCalledWith(
`slicer-auth-code:${code}`,
{
access_token: session.sessionToken,
expires_at: session.expiresAt,
...session.user,
},
60
);
});
it('consumes an auth code through the atomic Redis operation', async () => {
const tokenData = { access_token: 'session-token' };
redisMock.getAndDeleteKey.mockResolvedValueOnce(tokenData).mockResolvedValueOnce(null);
await expect(getAndConsumeSlicerAuthCode('slicer_code')).resolves.toEqual(tokenData);
await expect(getAndConsumeSlicerAuthCode('slicer_code')).resolves.toBeNull();
expect(redisMock.getAndDeleteKey).toHaveBeenCalledTimes(2);
});
});

View File

@ -0,0 +1,141 @@
import { jest } from '@jest/globals';
import express from 'express';
import request from 'supertest';
const printerId = '507f1f77bcf86cd799439011';
const storedFileId = '507f1f77bcf86cd799439012';
const printer = { _id: printerId, pendingSlicerUploads: [] };
const findById = jest.fn(() => ({
select: jest.fn(() => ({
lean: jest.fn().mockResolvedValue(printer),
})),
}));
const newObject = jest.fn();
const editObject = jest.fn();
const deleteObject = jest.fn();
const uploadFile = jest.fn();
const deleteFile = jest.fn();
const getFileMeta = jest.fn();
jest.unstable_mockModule('../../../config.js', () => ({
default: {
server: { logLevel: 'info' },
app: { urlClient: 'http://localhost:3000' },
},
}));
jest.unstable_mockModule('../../../database/schemas/management/file.schema.js', () => ({
fileModel: { modelName: 'file' },
}));
jest.unstable_mockModule('../../../database/schemas/production/printer.schema.js', () => ({
printerModel: { modelName: 'printer', findById },
}));
jest.unstable_mockModule('../../../database/database.js', () => ({
deleteObject,
editObject,
newObject,
}));
jest.unstable_mockModule('../../../database/ceph.js', () => ({
BUCKETS: { FILES: 'files' },
deleteFile,
uploadFile,
}));
jest.unstable_mockModule('../../../utils.js', () => ({
getFileMeta,
}));
jest.unstable_mockModule('../sessionStore.js', () => ({
createSlicerAuthCode: jest.fn(),
createSlicerSession: jest.fn(),
}));
jest.unstable_mockModule('log4js', () => ({
default: {
getLogger: () => ({
level: 'info',
debug: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
}),
},
}));
const { slicerUploadRouteHandler } = await import('../slicer.js');
describe('slicer upload', () => {
beforeEach(() => {
jest.clearAllMocks();
newObject.mockResolvedValue({ _id: storedFileId });
editObject.mockResolvedValue({ _id: printerId });
getFileMeta.mockResolvedValue({ generator: 'test-slicer' });
});
it('stores the multipart file bytes and parses OctoPrint boolean fields', async () => {
const app = express();
app.post(
'/slicer/:printerId/user/pass/api/files/local',
(req, _res, next) => {
req.user = { username: 'slicer-user' };
next();
},
slicerUploadRouteHandler
);
const fileContents = Buffer.from('G28\nG1 X10 Y20\n');
const response = await request(app)
.post(`/slicer/${printerId}/user/pass/api/files/local`)
.field('select', 'true')
.field('print', 'false')
.attach('file', fileContents, {
filename: 'part.gcode',
// Cura and other slicers may identify G-code as compressible text.
contentType: 'text/plain',
});
expect(response.status).toBe(201);
expect(uploadFile).toHaveBeenCalledWith(
'files',
`files/${storedFileId}.gcode`,
fileContents,
'application/octet-stream',
{
originalName: 'part.gcode',
uploadedBy: 'slicer-user',
}
);
expect(newObject).toHaveBeenCalledWith(
expect.objectContaining({
newData: expect.objectContaining({
name: 'part',
extension: '.gcode',
size: fileContents.length,
}),
})
);
expect(editObject).toHaveBeenCalledWith(
expect.objectContaining({
updateData: {
pendingSlicerUploads: [
expect.objectContaining({
file: storedFileId,
shouldPrint: false,
}),
],
},
})
);
expect(response.body).toEqual(
expect.objectContaining({
done: true,
effectiveSelect: true,
effectivePrint: false,
})
);
expect(response.headers.location).toMatch(/\/part\.gcode$/);
});
});

View File

@ -12,6 +12,8 @@ import {
getSession,
updateSessionKeycloakTokens,
deleteSession,
getAndConsumeSlicerAuthCode,
isSlicerAuthCode,
} from './sessionStore.js';
import { expandObjectIds } from '../../utils.js';
@ -128,6 +130,15 @@ export const loginTokenRouteHandler = async (req, res, redirectType = 'web') =>
}
try {
if (isSlicerAuthCode(code)) {
const slicerTokenData = await getAndConsumeSlicerAuthCode(code);
if (!slicerTokenData) {
return res.status(401).json({ error: 'Authorization code invalid or expired' });
}
logger.debug('Exchanged slicer auth code for session');
return res.status(200).json(slicerTokenData);
}
// Check for temporary email render auth code (30s TTL for Puppeteer)
const emailRenderData = getAndConsumeEmailRenderTokenData(code);
if (emailRenderData) {

View File

@ -12,6 +12,20 @@ const logger = log4js.getLogger('SessionStore');
logger.level = config.server.logLevel;
const SESSION_KEY_PREFIX = 'session:';
const SLICER_SESSION_KEY_PREFIX = 'slicer-session:';
const SLICER_AUTH_CODE_KEY_PREFIX = 'slicer-auth-code:';
const SLICER_AUTH_CODE_PREFIX = 'slicer_';
const SLICER_AUTH_CODE_TTL_SECONDS = 60;
const REPLACE_SLICER_SESSION_SCRIPT = `
local previousToken = redis.call('GET', KEYS[1])
redis.call('SET', KEYS[2], ARGV[1], 'EX', ARGV[2])
redis.call('SET', KEYS[1], ARGV[3], 'EX', ARGV[2])
if previousToken and previousToken ~= ARGV[3] then
redis.call('DEL', ARGV[4] .. previousToken)
end
return previousToken
`;
/**
* Generate a cryptographically secure session token
@ -61,6 +75,65 @@ export async function createSession({ user, keycloakTokens }) {
return { sessionToken, expiresAt };
}
/**
* Create a long-lived slicer UI session, replacing only the user's previous slicer session.
*/
export async function createSlicerSession(user) {
const sessionToken = generateSessionToken();
const expiresAtDate = new Date();
expiresAtDate.setFullYear(expiresAtDate.getFullYear() + 99);
const expiresAt = expiresAtDate.getTime();
const ttlSeconds = getTtlSeconds(expiresAt);
const sessionData = {
sessionToken,
user: userToSessionUser(user),
keycloakTokens: null,
expiresAt,
source: 'slicer',
};
console.log('sessionData', sessionData);
const userId = sessionData.user?._id;
if (!userId) {
throw new Error('Cannot create slicer session without a user id');
}
await redisServer.eval(
REPLACE_SLICER_SESSION_SCRIPT,
[SLICER_SESSION_KEY_PREFIX + userId, SESSION_KEY_PREFIX + sessionToken],
[JSON.stringify(sessionData), ttlSeconds, sessionToken, SESSION_KEY_PREFIX]
);
logger.debug(`Created replacement slicer session for user ${user.username}`);
return { sessionToken, expiresAt, user: sessionData.user };
}
/**
* Create a short-lived, one-time code which the UI can exchange for a slicer session.
*/
export async function createSlicerAuthCode({ sessionToken, expiresAt, user }) {
const authCode = SLICER_AUTH_CODE_PREFIX + generateSessionToken();
await redisServer.setKey(
SLICER_AUTH_CODE_KEY_PREFIX + authCode,
{
access_token: sessionToken,
expires_at: expiresAt,
...user,
},
SLICER_AUTH_CODE_TTL_SECONDS
);
return authCode;
}
export function isSlicerAuthCode(code) {
return typeof code === 'string' && code.startsWith(SLICER_AUTH_CODE_PREFIX);
}
export async function getAndConsumeSlicerAuthCode(code) {
if (!isSlicerAuthCode(code)) return null;
return redisServer.getAndDeleteKey(SLICER_AUTH_CODE_KEY_PREFIX + code);
}
/**
* Get session by token. Returns null if not found or expired.
*/

229
src/services/misc/slicer.js Normal file
View File

@ -0,0 +1,229 @@
import mongoose from 'mongoose';
import log4js from 'log4js';
import multer from 'multer';
import path from 'path';
import config from '../../config.js';
import { fileModel } from '../../database/schemas/management/file.schema.js';
import { printerModel } from '../../database/schemas/production/printer.schema.js';
import { deleteObject, editObject, newObject } from '../../database/database.js';
import { BUCKETS, deleteFile as deleteCephFile, uploadFile } from '../../database/ceph.js';
import { getFileMeta } from '../../utils.js';
import { createSlicerAuthCode, createSlicerSession } from './sessionStore.js';
const logger = log4js.getLogger('Slicer');
logger.level = config.server.logLevel;
const slicerFileUpload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 500000000 },
}).single('file');
const receiveSlicerFile = (req, res) =>
new Promise((resolve, reject) => {
slicerFileUpload(req, res, (error) => {
if (error) {
reject(error);
} else {
resolve();
}
});
});
const parseFormBoolean = (value) =>
value === true || (typeof value === 'string' && value.toLowerCase() === 'true');
const getSlicerContentType = (extension, reportedContentType) => {
// Slicers commonly label G-code as text/plain. That allows reverse proxies
// to compress the response later, which can invalidate a known byte length.
if (extension.toLowerCase() === '.gcode') {
return 'application/octet-stream';
}
return reportedContentType || 'application/octet-stream';
};
const SLICER_VERSION = {
server: '1.5.0',
api: '0.1',
text: 'OctoPrint (Moonraker v0.9.3-72-g7cdcca3)',
};
export const slicerUIRouteHandler = async (req, res) => {
try {
const { printerId } = req.params;
console.log('req.user', req.user);
const session = await createSlicerSession(req.user);
const authCode = await createSlicerAuthCode(session);
const uiUrl = new URL('/slicer', config.app.urlClient);
uiUrl.searchParams.set('printerId', printerId);
uiUrl.searchParams.set('authCode', authCode);
return res.redirect(uiUrl.toString());
} catch (error) {
logger.error('Failed to create slicer UI session:', error);
return res.status(500).json({ error: 'Failed to authenticate slicer UI' });
}
};
export const slicerVersionRouteHandler = async (req, res) => {
const { printerId } = req.params;
if (!mongoose.isValidObjectId(printerId)) {
return res.status(404).send({ error: 'Printer not found.', code: 404 });
}
const printer = await mongoose.connection
.collection('printers')
.findOne({ _id: new mongoose.Types.ObjectId(printerId) }, { projection: { _id: 1 } });
if (!printer) {
return res.status(404).send({ error: 'Printer not found.', code: 404 });
}
return res.json(SLICER_VERSION);
};
export const slicerUploadRouteHandler = async (req, res) => {
const { printerId } = req.params;
if (!mongoose.isValidObjectId(printerId)) {
return res.status(404).send({ error: 'Printer not found.', code: 404 });
}
const printer = await printerModel.findById(printerId).select('_id pendingSlicerUploads').lean();
if (!printer) {
return res.status(404).send({ error: 'Printer not found.', code: 404 });
}
try {
await receiveSlicerFile(req, res);
} catch (uploadError) {
logger.error('Error receiving slicer upload:', uploadError);
const status = uploadError.code === 'LIMIT_FILE_SIZE' ? 413 : 400;
return res.status(status).send({ error: uploadError.message });
}
// OctoPrint uploads use a multipart `file` part. Multer places the binary
// part on req.file and the accompanying select/print fields on req.body.
const file = req.file;
if (!file?.buffer || !file.originalname) {
return res.status(400).send({ error: 'No file selected.' });
}
const originalName = path.basename(file.originalname);
const extension = path.extname(originalName);
const baseName = path.parse(originalName).name;
if (!extension || !baseName) {
return res.status(400).send({ error: 'The uploaded file must have a valid filename.' });
}
const shouldPrint = parseFormBoolean(req.body?.print);
const shouldSelect = shouldPrint || parseFormBoolean(req.body?.select);
const contentType = getSlicerContentType(extension, file.mimetype);
let createdFile;
let cephKey;
try {
const meta = await getFileMeta({ ...file, originalname: originalName });
createdFile = await newObject({
model: fileModel,
newData: {
name: baseName,
type: contentType,
extension,
size: file.buffer.length,
metaData: {
originalName,
...meta,
},
},
user: req.user,
});
if (createdFile.error) {
logger.error('Error creating slicer file:', createdFile.error);
return res.status(createdFile.code || 500).send(createdFile);
}
cephKey = `files/${createdFile._id}${extension}`;
await uploadFile(BUCKETS.FILES, cephKey, file.buffer, contentType, {
originalName,
uploadedBy: req.user?.username || 'slicer',
});
const now = new Date();
const updatedPrinter = await editObject({
model: printerModel,
id: printerId,
updateData: {
pendingSlicerUploads: [
...(printer.pendingSlicerUploads || []),
{
file: createdFile,
gcodeFile: null,
shouldPrint,
new: true,
properties: {},
createdAt: now,
updatedAt: now,
},
],
},
user: req.user,
});
if (updatedPrinter.error) {
throw new Error(updatedPrinter.error);
}
logger.debug(`Slicer file ${createdFile._id} uploaded and queued for printer ${printerId}`);
const uploadPath = (req.originalUrl || req.url).split('?')[0].replace(/\/$/, '');
const resource = `${uploadPath}/${encodeURIComponent(originalName)}`;
res.location(resource);
return res.status(201).send({
files: {
local: {
name: originalName,
origin: 'local',
path: originalName,
type: 'machinecode',
typePath: ['machinecode', extension.slice(1).toLowerCase()],
refs: {
resource,
download: resource,
},
},
},
done: true,
effectiveSelect: shouldSelect,
effectivePrint: shouldPrint,
});
} catch (error) {
logger.error('Error processing slicer upload:', error);
if (cephKey) {
try {
await deleteCephFile(BUCKETS.FILES, cephKey);
} catch (cleanupError) {
logger.error('Error cleaning up slicer file from storage:', cleanupError);
}
}
if (createdFile?._id) {
try {
await deleteObject({
model: fileModel,
id: createdFile._id,
user: req.user,
});
} catch (cleanupError) {
logger.error('Error cleaning up slicer file record:', cleanupError);
}
}
return res.status(500).send({ error: error.message });
}
};

View File

@ -0,0 +1,137 @@
import { jest } from '@jest/globals';
jest.unstable_mockModule('../../../database/database.js', () => ({
deleteObject: jest.fn(),
editObject: jest.fn(),
getObject: jest.fn(),
listObjects: jest.fn(),
listObjectsByProperties: jest.fn(),
newObject: jest.fn(),
searchObjects: jest.fn(),
}));
jest.unstable_mockModule('../../../database/schemas/production/filamentprofile.schema.js', () => ({
filamentProfileModel: { modelName: 'filamentProfile' },
}));
jest.unstable_mockModule('log4js', () => ({
default: {
getLogger: () => ({
level: 'info',
debug: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
}),
},
}));
const {
deleteFilamentProfileRouteHandler,
editFilamentProfileRouteHandler,
listFilamentProfilesRouteHandler,
newFilamentProfileRouteHandler,
} = await import('../filamentprofiles.js');
const { deleteObject, editObject, listObjects, newObject } =
await import('../../../database/database.js');
const { filamentProfileModel } =
await import('../../../database/schemas/production/filamentprofile.schema.js');
describe('Filament profile service route handlers', () => {
let req;
let res;
beforeEach(() => {
req = {
params: {},
body: {},
user: { id: 'test-user-id' },
};
res = {
send: jest.fn(),
status: jest.fn().mockReturnThis(),
};
jest.clearAllMocks();
});
it('lists filament profiles', async () => {
const result = [{ _id: '1', name: 'PETG profile' }];
listObjects.mockResolvedValue(result);
await listFilamentProfilesRouteHandler(req, res);
expect(listObjects).toHaveBeenCalledWith(
expect.objectContaining({
model: filamentProfileModel,
populate: ['filament', 'compatiblePrinters'],
})
);
expect(res.send).toHaveBeenCalledWith(result);
});
it('creates profiles using only supported Orca fields', async () => {
const profileBody = {
name: 'Prusa Generic PETG',
filamentType: 'filament',
filament: '507f1f77bcf86cd799439011',
filamentFlowRatio: 0.98,
enablePressureAdvance: false,
pressureAdvance: 0.02,
adaptivePressureAdvance: false,
nozzleTemperatureInitialLayer: 230,
nozzleTemperature: 240,
hotPlateTempInitialLayer: 85,
hotPlateTemp: 85,
filamentAdaptiveVolumetricSpeed: false,
filamentMaxVolumetricSpeed: 8,
closeFanTheFirstXLayers: 3,
fanMinSpeed: 40,
fanMaxSpeed: 90,
activateAirFiltration: false,
compatiblePrinters: ['507f1f77bcf86cd799439012'],
};
req.body = { ...profileBody, unexpected: 'ignored' };
newObject.mockImplementation(async ({ newData }) => ({ _id: 'profile-id', ...newData }));
await newFilamentProfileRouteHandler(req, res);
const { newData } = newObject.mock.calls[0][0];
expect(newData).toEqual(profileBody);
expect(newData).not.toHaveProperty('unexpected');
});
it('edits only supplied supported Orca fields', async () => {
req.params.id = '507f1f77bcf86cd799439011';
req.body = {
name: 'Updated PETG profile',
filamentFlowRatio: 1,
unexpected: 'ignored',
};
editObject.mockResolvedValue({ _id: req.params.id, name: req.body.name });
await editFilamentProfileRouteHandler(req, res);
expect(editObject).toHaveBeenCalledWith(
expect.objectContaining({
model: filamentProfileModel,
updateData: {
updatedAt: expect.any(Date),
name: 'Updated PETG profile',
filamentFlowRatio: 1,
},
})
);
});
it('deletes profiles', async () => {
req.params.id = '507f1f77bcf86cd799439011';
const result = { _id: req.params.id };
deleteObject.mockResolvedValue(result);
await deleteFilamentProfileRouteHandler(req, res);
expect(deleteObject).toHaveBeenCalledWith(
expect.objectContaining({ model: filamentProfileModel, user: req.user })
);
expect(res.send).toHaveBeenCalledWith(result);
});
});

View File

@ -0,0 +1,158 @@
import { jest } from '@jest/globals';
jest.unstable_mockModule('../../../database/database.js', () => ({
deleteObject: jest.fn(),
editObject: jest.fn(),
getObject: jest.fn(),
listObjects: jest.fn(),
listObjectsByProperties: jest.fn(),
newObject: jest.fn(),
searchObjects: jest.fn(),
}));
jest.unstable_mockModule('../../../database/schemas/production/printerprofile.schema.js', () => ({
printerProfileModel: { modelName: 'printerProfile' },
}));
jest.unstable_mockModule('log4js', () => ({
default: {
getLogger: () => ({
level: 'info',
debug: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
}),
},
}));
const {
deletePrinterProfileRouteHandler,
editPrinterProfileRouteHandler,
listPrinterProfilesRouteHandler,
newPrinterProfileRouteHandler,
} = await import('../printerprofiles.js');
const { deleteObject, editObject, listObjects, newObject } =
await import('../../../database/database.js');
const { printerProfileModel } =
await import('../../../database/schemas/production/printerprofile.schema.js');
describe('Printer profile service route handlers', () => {
let req;
let res;
beforeEach(() => {
req = {
params: {},
body: {},
user: { id: 'test-user-id' },
};
res = {
send: jest.fn(),
status: jest.fn().mockReturnThis(),
};
jest.clearAllMocks();
});
it('lists printer profiles', async () => {
const result = [{ _id: '1', name: 'Orca profile' }];
listObjects.mockResolvedValue(result);
await listPrinterProfilesRouteHandler(req, res);
expect(listObjects).toHaveBeenCalledWith(
expect.objectContaining({ model: printerProfileModel, populate: ['printer'] })
);
expect(res.send).toHaveBeenCalledWith(result);
});
it('creates profiles using only supported Orca fields', async () => {
const profileBody = {
name: 'Orca profile',
printer: '507f1f77bcf86cd799439011',
extruders: [
{
nozzleDiameter: 0.4,
nozzleType: 'hardened_steel',
minLayerHeight: 0.08,
maxLayerHeight: 0.3,
positionOffsetX: 0,
positionOffsetY: 0,
},
],
printBedWidth: 220,
printBedHeight: 220,
originX: 0,
originY: 0,
bedExcludeArea: [{ x: 0, y: 0 }],
printableHeight: 250,
supportMultiBedTypes: false,
bestObjectPositionX: 110,
bestObjectPositionY: 110,
zOffset: 0,
preferredOrientation: 0,
machineMaxAccelerationRetracting: { min: 5000, max: 5000 },
machineMaxSpeedE: { min: 30, max: 30 },
machineMaxSpeedX: { min: 500, max: 500 },
machineMaxSpeedY: { min: 500, max: 500 },
machinePauseGcode: 'PAUSE',
machineStartGcode: 'START_PRINT',
machineEndGcode: 'END_PRINT',
layerChangeGcode: 'LAYER_CHANGE',
beforeLayerChangeGcode: 'BEFORE_LAYER_CHANGE',
printerNotes: 'Test printer',
scanFirstLayer: true,
machineLoadFilamentTime: 20,
machineUnloadFilamentTime: 15,
thumbnails: [
{ width: 32, height: 32 },
{ width: 300, height: 300 }
],
auxiliaryFan: true,
machineMaxJunctionDeviation: { min: 0.02, max: 0.02 },
};
req.body = { ...profileBody, unexpected: 'ignored' };
newObject.mockImplementation(async ({ newData }) => ({ _id: 'profile-id', ...newData }));
await newPrinterProfileRouteHandler(req, res);
const { newData } = newObject.mock.calls[0][0];
expect(newData).toEqual(profileBody);
expect(newData).not.toHaveProperty('unexpected');
});
it('edits only supplied supported Orca fields', async () => {
req.params.id = '507f1f77bcf86cd799439011';
req.body = {
name: 'Updated profile',
extruders: [{ nozzleDiameter: 0.4 }],
unexpected: 'ignored',
};
editObject.mockResolvedValue({ _id: req.params.id, name: req.body.name });
await editPrinterProfileRouteHandler(req, res);
expect(editObject).toHaveBeenCalledWith(
expect.objectContaining({
model: printerProfileModel,
updateData: {
updatedAt: expect.any(Date),
name: 'Updated profile',
extruders: [{ nozzleDiameter: 0.4 }],
},
})
);
});
it('deletes profiles', async () => {
req.params.id = '507f1f77bcf86cd799439011';
const result = { _id: req.params.id };
deleteObject.mockResolvedValue(result);
await deletePrinterProfileRouteHandler(req, res);
expect(deleteObject).toHaveBeenCalledWith(
expect.objectContaining({ model: printerProfileModel, user: req.user })
);
expect(res.send).toHaveBeenCalledWith(result);
});
});

View File

@ -65,20 +65,32 @@ describe('Printer Service Route Handlers', () => {
await listPrintersRouteHandler(req, res);
expect(listObjects).toHaveBeenCalledWith(expect.objectContaining({ model: printerModel }));
expect(listObjects).toHaveBeenCalledWith(
expect.objectContaining({
model: printerModel,
populate: ['host'],
})
);
expect(res.send).toHaveBeenCalledWith(mockResult);
});
});
describe('newPrinterRouteHandler', () => {
it('should create a new printer', async () => {
req.body = { name: 'New Printer', host: 'host123' };
req.body = {
name: 'New Printer',
host: 'host123',
};
const mockPrinter = { _id: '456', ...req.body };
newObject.mockResolvedValue(mockPrinter);
await newPrinterRouteHandler(req, res);
expect(newObject).toHaveBeenCalled();
expect(newObject).toHaveBeenCalledWith(
expect.objectContaining({
newData: expect.objectContaining({ host: 'host123' }),
})
);
expect(res.send).toHaveBeenCalledWith(mockPrinter);
});
});
@ -86,13 +98,18 @@ describe('Printer Service Route Handlers', () => {
describe('editPrinterRouteHandler', () => {
it('should update a printer', async () => {
req.params.id = '507f1f77bcf86cd799439011';
req.body = { name: 'Updated Printer' };
req.body = { name: 'Updated Printer', host: 'host456' };
const mockResult = { _id: '507f1f77bcf86cd799439011', ...req.body };
editObject.mockResolvedValue(mockResult);
await editPrinterRouteHandler(req, res);
expect(editObject).toHaveBeenCalled();
expect(editObject).toHaveBeenCalledWith(
expect.objectContaining({
updateData: expect.objectContaining({ host: 'host456' }),
populate: ['vendor', 'host'],
})
);
expect(res.send).toHaveBeenCalledWith(mockResult);
});
});

View File

@ -0,0 +1,305 @@
import config from '../../config.js';
import { filamentProfileModel } from '../../database/schemas/production/filamentprofile.schema.js';
import log4js from 'log4js';
import mongoose from 'mongoose';
import {
deleteObject,
editObject,
getObject,
listObjects,
listObjectsByProperties,
newObject,
searchObjects,
} from '../../database/database.js';
const logger = log4js.getLogger('FilamentProfiles');
logger.level = config.server.logLevel;
const FILAMENT_PROFILE_POPULATE = ['filament', 'compatiblePrinters'];
export const filamentProfileFields = [
'name',
'filamentType',
'filament',
'filamentIsSupport',
'filamentSoluble',
'filamentPrintable',
'filamentAdhesivenessCategory',
'temperatureVitrification',
'idleTemperature',
'pelletFlowCoefficient',
'requiredNozzleHrc',
'filamentFlowRatio',
'enablePressureAdvance',
'pressureAdvance',
'adaptivePressureAdvance',
'adaptivePressureAdvanceBridges',
'adaptivePressureAdvanceOverhangs',
'adaptivePressureAdvanceModel',
'activateChamberTempControl',
'chamberTemperature',
'chamberMinimalTemperature',
'nozzleTemperatureInitialLayer',
'nozzleTemperature',
'nozzleTemperatureRangeLow',
'nozzleTemperatureRangeHigh',
'hotPlateTempInitialLayer',
'hotPlateTemp',
'coolPlateTempInitialLayer',
'coolPlateTemp',
'engPlateTempInitialLayer',
'engPlateTemp',
'texturedPlateTempInitialLayer',
'texturedPlateTemp',
'texturedCoolPlateTempInitialLayer',
'texturedCoolPlateTemp',
'supertackPlateTempInitialLayer',
'supertackPlateTemp',
'filamentAdaptiveVolumetricSpeed',
'filamentMaxVolumetricSpeed',
'volumetricSpeedCoefficients',
'closeFanTheFirstXLayers',
'fullFanSpeedLayer',
'fanMinSpeed',
'fanMaxSpeed',
'reduceFanStopStartFreq',
'slowDownForLayerCooling',
'dontSlowDownOuterWall',
'slowDownMinSpeed',
'slowDownLayerTime',
'fanCoolingLayerTime',
'enableOverhangBridgeFan',
'overhangFanThreshold',
'overhangFanSpeed',
'internalBridgeFanSpeed',
'supportMaterialInterfaceFanSpeed',
'ironingFanSpeed',
'initialLayerFanSpeed',
'firstXLayerFanSpeed',
'additionalCoolingFanSpeed',
'additionalFanFullSpeedLayer',
'closeAdditionalFanFirstXLayers',
'activateAirFiltration',
'activateAirFiltrationDuringPrint',
'activateAirFiltrationOnCompletion',
'duringPrintExhaustFanSpeed',
'completePrintExhaustFanSpeed',
'filamentRetractionLength',
'filamentRetractionSpeed',
'filamentDeretractionSpeed',
'filamentRetractionMinimumTravel',
'filamentRetractWhenChangingLayer',
'filamentRetractBeforeWipe',
'filamentRetractAfterWipe',
'filamentRetractRestartExtra',
'filamentRetractLiftAbove',
'filamentRetractLiftBelow',
'filamentRetractLiftEnforce',
'filamentWipe',
'filamentWipeDistance',
'filamentZHop',
'filamentZHopTypes',
'filamentLongRetractionsWhenCut',
'filamentRetractionDistancesWhenCut',
'longRetractionsWhenEc',
'retractionDistancesWhenEc',
'filamentLoadingSpeed',
'filamentLoadingSpeedStart',
'filamentUnloadingSpeed',
'filamentUnloadingSpeedStart',
'filamentChangeLength',
'filamentChangeLengthNc',
'filamentToolchangeDelay',
'filamentExtruderCompatibility',
'filamentExtruderVariant',
'filamentMultitoolRamming',
'filamentMultitoolRammingFlow',
'filamentMultitoolRammingVolume',
'filamentRammingParameters',
'filamentRammingTravelTime',
'filamentRammingTravelTimeNc',
'filamentRammingVolumetricSpeed',
'filamentRammingVolumetricSpeedNc',
'filamentMinimalPurgeOnWipeTower',
'filamentTowerInterfacePreExtrusionDist',
'filamentTowerInterfacePreExtrusionLength',
'filamentTowerInterfacePrintTemp',
'filamentTowerInterfacePurgeVolume',
'filamentTowerIroningArea',
'filamentCoolingBeforeTower',
'filamentCoolingInitialSpeed',
'filamentCoolingFinalSpeed',
'filamentCoolingMoves',
'filamentFlushTemp',
'filamentFlushTempFast',
'filamentFlushVolumetricSpeed',
'filamentPreCoolingTemperature',
'filamentPreCoolingTemperatureNc',
'filamentPreheatTemperatureDelta',
'filamentPrimeVolumeNc',
'filamentRetractLengthNc',
'filamentStampingDistance',
'filamentStampingLoadingSpeed',
'filamentStartGcode',
'filamentEndGcode',
'filamentChangeExtrusionRoleGcode',
'filamentShrink',
'filamentShrinkageCompensationZ',
'filamentDevAmsDryingTemperature',
'filamentDevAmsDryingTime',
'filamentDevAmsDryingHeatDistortionTemperature',
'filamentDevAmsDryingAmsLimitations',
'filamentDevChamberDryingBedTemperature',
'filamentDevChamberDryingTime',
'filamentDevDryingCoolingTemperature',
'filamentDevDryingSofteningTemperature',
'compatiblePrinters',
'compatiblePrintersCondition',
'compatiblePrints',
'compatiblePrintsCondition',
'filamentNotes',
];
const pickFilamentProfileFields = (body = {}) =>
Object.fromEntries(
filamentProfileFields
.filter((field) => Object.hasOwn(body, field))
.map((field) => [field, body[field]])
);
export const listFilamentProfilesRouteHandler = async (
req,
res,
page = 1,
limit = 25,
property = '',
filter = {},
search = '',
sort = '',
order = 'ascend'
) => {
const result = await listObjects({
model: filamentProfileModel,
page,
limit,
property,
filter,
search,
sort,
order,
populate: FILAMENT_PROFILE_POPULATE,
});
if (result?.error) {
logger.error('Error listing filament profiles.');
return res.status(result.code).send(result);
}
logger.debug(`List of filament profiles (Page ${page}, Limit ${limit}). Count: ${result.length}`);
res.send(result);
};
export const listFilamentProfilesByPropertiesRouteHandler = async (
req,
res,
properties = [],
filter = {}
) => {
const result = await listObjectsByProperties({
model: filamentProfileModel,
properties,
filter,
populate: FILAMENT_PROFILE_POPULATE,
});
if (result?.error) {
logger.error('Error listing filament profiles.');
return res.status(result.code).send(result);
}
logger.debug(`List of filament profiles. Count: ${result.length}`);
res.send(result);
};
export const searchFilamentProfilesRouteHandler = async (req, res, search) => {
const result = await searchObjects({
model: filamentProfileModel,
search,
});
res.send(result);
};
export const getFilamentProfileRouteHandler = async (req, res) => {
const id = req.params.id;
const result = await getObject({
model: filamentProfileModel,
id,
populate: FILAMENT_PROFILE_POPULATE,
});
if (result?.error) {
logger.warn('Filament profile not found with supplied id.');
return res.status(result.code).send(result);
}
logger.debug(`Retrieved filament profile with ID: ${id}`);
res.send(result);
};
export const editFilamentProfileRouteHandler = async (req, res) => {
const id = new mongoose.Types.ObjectId(req.params.id);
const updateData = {
updatedAt: new Date(),
...pickFilamentProfileFields(req.body),
};
const result = await editObject({
model: filamentProfileModel,
id,
updateData,
user: req.user,
});
if (result?.error) {
logger.error('Error editing filament profile:', result.error);
return res.status(result.code).send(result);
}
logger.debug(`Edited filament profile with ID: ${id}`);
res.send(result);
};
export const newFilamentProfileRouteHandler = async (req, res) => {
const newData = {
...pickFilamentProfileFields(req.body),
};
const result = await newObject({
model: filamentProfileModel,
newData,
user: req.user,
});
if (result?.error) {
logger.error('No filament profile created:', result.error);
return res.status(result.code).send(result);
}
logger.debug(`New filament profile with ID: ${result._id}`);
res.send(result);
};
export const deleteFilamentProfileRouteHandler = async (req, res) => {
const id = new mongoose.Types.ObjectId(req.params.id);
const result = await deleteObject({
model: filamentProfileModel,
id,
user: req.user,
});
if (result?.error) {
logger.error('No filament profile deleted:', result.error);
return res.status(result.code).send(result);
}
logger.debug(`Deleted filament profile with ID: ${result._id}`);
res.send(result);
};

View File

@ -0,0 +1,193 @@
import config from '../../config.js';
import { printerProfileModel } from '../../database/schemas/production/printerprofile.schema.js';
import log4js from 'log4js';
import mongoose from 'mongoose';
import {
deleteObject,
editObject,
getObject,
listObjects,
listObjectsByProperties,
newObject,
searchObjects,
} from '../../database/database.js';
const logger = log4js.getLogger('PrinterProfiles');
logger.level = config.server.logLevel;
export const printerProfileFields = [
'name',
'printer',
'extruders',
'printBedWidth',
'printBedHeight',
'originX',
'originY',
'bedExcludeArea',
'printableHeight',
'supportMultiBedTypes',
'bestObjectPositionX',
'bestObjectPositionY',
'zOffset',
'preferredOrientation',
'machineMaxAccelerationRetracting',
'machineMaxSpeedE',
'machineMaxSpeedX',
'machineMaxSpeedY',
'machinePauseGcode',
'machineStartGcode',
'machineEndGcode',
'layerChangeGcode',
'beforeLayerChangeGcode',
'printerNotes',
'scanFirstLayer',
'machineLoadFilamentTime',
'machineUnloadFilamentTime',
'thumbnails',
'auxiliaryFan',
'machineMaxJunctionDeviation',
];
const pickPrinterProfileFields = (body = {}) =>
Object.fromEntries(
printerProfileFields
.filter((field) => Object.hasOwn(body, field))
.map((field) => [field, body[field]])
);
export const listPrinterProfilesRouteHandler = async (
req,
res,
page = 1,
limit = 25,
property = '',
filter = {},
search = '',
sort = '',
order = 'ascend'
) => {
const result = await listObjects({
model: printerProfileModel,
page,
limit,
property,
filter,
search,
sort,
order,
populate: ['printer'],
});
if (result?.error) {
logger.error('Error listing printer profiles.');
return res.status(result.code).send(result);
}
logger.debug(`List of printer profiles (Page ${page}, Limit ${limit}). Count: ${result.length}`);
res.send(result);
};
export const listPrinterProfilesByPropertiesRouteHandler = async (
req,
res,
properties = [],
filter = {}
) => {
const result = await listObjectsByProperties({
model: printerProfileModel,
properties,
filter,
populate: ['printer'],
});
if (result?.error) {
logger.error('Error listing printer profiles.');
return res.status(result.code).send(result);
}
logger.debug(`List of printer profiles. Count: ${result.length}`);
res.send(result);
};
export const searchPrinterProfilesRouteHandler = async (req, res, search) => {
const result = await searchObjects({
model: printerProfileModel,
search,
});
res.send(result);
};
export const getPrinterProfileRouteHandler = async (req, res) => {
const id = req.params.id;
const result = await getObject({
model: printerProfileModel,
id,
populate: ['printer'],
});
if (result?.error) {
logger.warn('Printer profile not found with supplied id.');
return res.status(result.code).send(result);
}
logger.debug(`Retrieved printer profile with ID: ${id}`);
res.send(result);
};
export const editPrinterProfileRouteHandler = async (req, res) => {
const id = new mongoose.Types.ObjectId(req.params.id);
const updateData = {
updatedAt: new Date(),
...pickPrinterProfileFields(req.body),
};
const result = await editObject({
model: printerProfileModel,
id,
updateData,
user: req.user,
});
if (result?.error) {
logger.error('Error editing printer profile:', result.error);
return res.status(result.code).send(result);
}
logger.debug(`Edited printer profile with ID: ${id}`);
res.send(result);
};
export const newPrinterProfileRouteHandler = async (req, res) => {
const newData = {
...pickPrinterProfileFields(req.body),
};
const result = await newObject({
model: printerProfileModel,
newData,
user: req.user,
});
if (result?.error) {
logger.error('No printer profile created:', result.error);
return res.status(result.code).send(result);
}
logger.debug(`New printer profile with ID: ${result._id}`);
res.send(result);
};
export const deletePrinterProfileRouteHandler = async (req, res) => {
const id = new mongoose.Types.ObjectId(req.params.id);
const result = await deleteObject({
model: printerProfileModel,
id,
user: req.user,
});
if (result?.error) {
logger.error('No printer profile deleted:', result.error);
return res.status(result.code).send(result);
}
logger.debug(`Deleted printer profile with ID: ${result._id}`);
res.send(result);
};

View File

@ -86,9 +86,16 @@ export const getPrinterRouteHandler = async (req, res) => {
const result = await getObject({
model: printerModel,
id,
populate: ['vendor', 'host'],
populate: [
'vendor',
'host',
{ path: 'pendingSlicerUploads.file', strictPopulate: false },
{ path: 'pendingSlicerUploads.gcodeFile', strictPopulate: false },
{ path: 'pendingSlicerUploads.job', strictPopulate: false },
],
});
if (result?.error) {
console.log('result', result.error);
logger.warn(`Printer not found with supplied id.`);
return res.status(result.code).send(result);
}
@ -109,6 +116,7 @@ export const editPrinterRouteHandler = async (req, res) => {
tags: req.body.tags,
vendor: req.body.vendor,
host: req.body.host,
pendingSlicerUploads: req.body.pendingSlicerUploads,
active: req.body.active,
};
// Create audit log before updating