Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/config/db.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,6 @@ db.CustomerPayment = require('../models/customerpayment.model.js')(sequelize, Se
db.InvoiceJob = require('../models/invoicejob.model.js')(sequelize, Sequelize);
db.ProductEntry = require('../models/productentry.model.js')(sequelize, Sequelize);
db.VersionInfo = require('../models/versioninfo.model.js')(sequelize, Sequelize);
db.PurchaseOrderVendor = require('../models/purchaseordervendor.model.js')(sequelize, Sequelize);

module.exports = db;
50 changes: 50 additions & 0 deletions app/config/openapi.js
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,30 @@ const versionInfoSchema = {
},
};

const purchaseOrderVendorSchema = {
type: 'object',
properties: {
povId: { type: 'integer', readOnly: true },
povName: { type: 'string' },
povMailingAddress1: { type: 'string' },
povMailingAddress2: { type: 'string' },
povMailingCity: { type: 'string' },
povMailingState: { type: 'string' },
povMailingCountry: { type: 'string' },
povMailingZip: { type: 'string' },
povBillingAddress1: { type: 'string' },
povBillingAddress2: { type: 'string' },
povBillingCity: { type: 'string' },
povBillingState: { type: 'string' },
povBillingCountry: { type: 'string' },
povBillingZip: { type: 'string' },
povPhone: { type: 'string' },
povEMail: { type: 'string', format: 'email' },
povCompId: { type: 'integer' },
povArch: { type: 'boolean', readOnly: true },
},
};

const timeEntrySchema = {
type: 'object',
properties: {
Expand Down Expand Up @@ -220,6 +244,7 @@ const spec = {
InvoiceJob: invoiceJobSchema,
ProductEntry: productEntrySchema,
VersionInfo: versionInfoSchema,
PurchaseOrderVendor: purchaseOrderVendorSchema,
Error: errorResponse,
},
},
Expand Down Expand Up @@ -664,6 +689,31 @@ const spec = {
patch: { summary: 'Partial update of a version info (master keys only)', security: [{ authKey: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }], requestBody: { content: { 'application/json': { schema: { $ref: '#/components/schemas/VersionInfo' } } } }, responses: { 200: { description: 'Updated' }, 400: { description: 'No updatable fields supplied' }, 404: { description: 'Not found' }, 403: { description: 'Non-master key' } } },
delete: { summary: 'Hard-delete a version info (master keys only — no archive column on this table)', security: [{ authKey: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }], responses: { 200: { description: 'Deleted' }, 404: { description: 'Not found' }, 403: { description: 'Non-master key' } } },
},
'/v1/purchaseordervendor': {
post: {
summary: 'Create a PO vendor',
security: [{ authKey: [] }],
requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/PurchaseOrderVendor' } } } },
responses: { 201: { description: 'Created' }, 400: { description: 'Bad request' }, 403: { description: 'Auth failure' } },
},
},
'/v1/purchaseordervendor/{id}': {
get: { summary: 'Get one PO vendor', security: [{ authKey: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }], responses: { 200: { description: 'Found' }, 404: { description: 'Not found' }, 403: { description: 'Auth failure' } } },
patch: { summary: 'Partial update of a PO vendor', security: [{ authKey: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }], requestBody: { content: { 'application/json': { schema: { $ref: '#/components/schemas/PurchaseOrderVendor' } } } }, responses: { 200: { description: 'Updated' }, 400: { description: 'No updatable fields supplied' }, 404: { description: 'Not found' }, 403: { description: 'Auth failure' } } },
delete: { summary: 'Soft-delete a PO vendor', security: [{ authKey: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }], responses: { 200: { description: 'Archived' }, 404: { description: 'Not found' }, 403: { description: 'Auth failure' } } },
},
'/v1/purchaseordervendor/bycompany/{id}': {
get: {
summary: 'List PO vendors in a company (paginated)',
security: [{ authKey: [] }],
parameters: [
{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } },
{ name: 'limit', in: 'query', schema: { type: 'integer', default: 100, maximum: 500 } },
{ name: 'offset', in: 'query', schema: { type: 'integer', default: 0 } },
],
responses: { 200: { description: 'OK' }, 400: { description: 'Invalid company id' }, 403: { description: 'Auth failure' } },
},
},
},
};

Expand Down
235 changes: 235 additions & 0 deletions app/controllers/purchaseordervendorcontroller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 Aaron K. Clark
"use strict";

/**
* PurchaseOrderVendor controller — direct compId scoping via povCompId.
* Same auth shape as Worker/BillingType/InventoryItem.
*/

const db = require('../config/db.config.js');
const log = require('../config/logger.js');
const auth = require('../middleware/auth.js');
const PurchaseOrderVendor = db.PurchaseOrderVendor;

const IsMaster = auth.isMaster;
const GetCompanyId = auth.getCompanyId;

// The schema whitelist already validates fields; we re-state it here so
// the controller doesn't trust the request body verbatim (mass-assignment
// defense — a forged povId in the body wouldn't make it through this
// allowlist even if the schema were bypassed).
const ALLOWED_FIELDS_CREATE = [
'povName', 'povMailingAddress1', 'povMailingAddress2', 'povMailingCity',
'povMailingState', 'povMailingCountry', 'povMailingZip',
'povBillingAddress1', 'povBillingAddress2', 'povBillingCity',
'povBillingState', 'povBillingCountry', 'povBillingZip',
'povPhone', 'povEMail', 'povCompId',
];
const ALLOWED_FIELDS_UPDATE = ALLOWED_FIELDS_CREATE.filter(f => f !== 'povCompId');

exports.create = async (req, res) => {
const authKey = req.get('authKey');
if (!authKey) {
return res.status(403).json({ message: "Authorization key not sent." });
}

let isAuthKeyMasterKey;
try {
isAuthKeyMasterKey = await IsMaster(authKey);
} catch (error) {
log.error({ err: error }, 'IsMaster failed');
return res.status(500).json({ message: "Error!", error: String(error) });
}

const body = req.body || {};
const payload = {};
for (const f of ALLOWED_FIELDS_CREATE) {
if (body[f] !== undefined) payload[f] = body[f];
}

if (!isAuthKeyMasterKey) {
let authKeyCompanyId;
try {
authKeyCompanyId = await GetCompanyId(authKey);
} catch (error) {
log.error({ err: error }, 'GetCompanyId failed');
return res.status(500).json({ message: "Error!", error: String(error) });
}
if (authKeyCompanyId === -1) {
return res.status(403).json({ message: "Invalid Authorization Key." });
}
if (payload.povCompId !== undefined && Number(payload.povCompId) !== authKeyCompanyId) {
return res.status(403).json({
message: "Cannot create a PO vendor for a company you do not belong to.",
});
}
payload.povCompId = authKeyCompanyId;
} else {
if (payload.povCompId === undefined || Number(payload.povCompId) <= 0) {
return res.status(400).json({
message: "Master-key requests must specify povCompId.",
});
}
}

payload.povArch = false;

try {
const created = await PurchaseOrderVendor.create(payload);
return res.status(201).json({ message: "PO vendor created.", purchaseOrderVendor: created });
} catch (error) {
log.error({ err: error }, 'PurchaseOrderVendor.create failed');
return res.status(500).json({ message: "Error!", error: String(error) });
}
};

exports.getById = async (req, res) => {
const authKey = req.get('authKey');
if (!authKey) {
return res.status(403).json({ message: "Authorization key not sent." });
}

let vendor;
try {
vendor = await PurchaseOrderVendor.findByPk(req.params.id);
} catch (error) {
log.error({ err: error }, 'PurchaseOrderVendor.findByPk failed');
return res.status(500).json({ message: "Error!", error: String(error) });
}
if (!vendor || vendor.povArch) {
return res.status(404).json({ message: "Not found." });
}

const isMaster = await IsMaster(authKey);
if (!isMaster) {
const companyId = await GetCompanyId(authKey);
if (companyId === -1 || vendor.povCompId !== companyId) {
return res.status(403).json({ message: "Invalid Authorization Key." });
}
}
return res.status(200).json({ message: "Found.", purchaseOrderVendor: vendor });
};

exports.listByCompany = async (req, res) => {
const authKey = req.get('authKey');
if (!authKey) {
return res.status(403).json({ message: "Authorization key not sent." });
}

const targetCompanyId = Number(req.params.id);
if (!Number.isInteger(targetCompanyId) || targetCompanyId <= 0) {
return res.status(400).json({ message: "Invalid company id." });
}

const isMaster = await IsMaster(authKey);
if (!isMaster) {
const companyId = await GetCompanyId(authKey);
if (companyId === -1 || companyId !== targetCompanyId) {
return res.status(403).json({ message: "Invalid Authorization Key." });
}
}

const requestedLimit = parseInt(req.query.limit, 10);
const limit = Number.isInteger(requestedLimit) && requestedLimit > 0
? Math.min(requestedLimit, 500)
: 100;
const requestedOffset = parseInt(req.query.offset, 10);
const offset = Number.isInteger(requestedOffset) && requestedOffset >= 0
? requestedOffset
: 0;

try {
const { count, rows } = await PurchaseOrderVendor.findAndCountAll({
where: { povCompId: targetCompanyId, povArch: false },
limit, offset,
order: [['povId', 'ASC']],
});
return res.status(200).json({
message: "Successfully retrieved PO vendors with CompanyId " + targetCompanyId,
count, limit, offset, purchaseOrderVendors: rows,
});
} catch (error) {
log.error({ err: error }, 'PurchaseOrderVendor.findAndCountAll failed');
return res.status(500).json({ message: "Error!", error: String(error) });
}
};

exports.update = async (req, res) => {
const authKey = req.get('authKey');
if (!authKey) {
return res.status(403).json({ message: "Authorization key not sent." });
}

let vendor;
try {
vendor = await PurchaseOrderVendor.findByPk(req.params.id);
} catch (error) {
log.error({ err: error }, 'PurchaseOrderVendor.findByPk failed');
return res.status(500).json({ message: "Error!", error: String(error) });
}
if (!vendor || vendor.povArch) {
return res.status(404).json({ message: "Not found." });
}

const isMaster = await IsMaster(authKey);
if (!isMaster) {
const companyId = await GetCompanyId(authKey);
if (companyId === -1 || vendor.povCompId !== companyId) {
return res.status(403).json({ message: "Invalid Authorization Key." });
}
}

const body = req.body || {};
const updates = {};
for (const f of ALLOWED_FIELDS_UPDATE) {
if (body[f] !== undefined) updates[f] = body[f];
}
if (Object.keys(updates).length === 0) {
return res.status(400).json({ message: "No updatable fields supplied." });
}

try {
await vendor.update(updates);
return res.status(200).json({ message: "Updated.", purchaseOrderVendor: vendor });
} catch (error) {
log.error({ err: error }, 'PurchaseOrderVendor.update failed');
return res.status(500).json({ message: "Error!", error: String(error) });
}
};

exports.remove = async (req, res) => {
const authKey = req.get('authKey');
if (!authKey) {
return res.status(403).json({ message: "Authorization key not sent." });
}

let vendor;
try {
vendor = await PurchaseOrderVendor.findByPk(req.params.id);
} catch (error) {
log.error({ err: error }, 'PurchaseOrderVendor.findByPk failed');
return res.status(500).json({ message: "Error!", error: String(error) });
}
if (!vendor || vendor.povArch) {
return res.status(404).json({ message: "Not found." });
}

const isMaster = await IsMaster(authKey);
if (!isMaster) {
const companyId = await GetCompanyId(authKey);
if (companyId === -1 || vendor.povCompId !== companyId) {
return res.status(403).json({ message: "Invalid Authorization Key." });
}
}

try {
await vendor.update({ povArch: true });
return res.status(200).json({ message: "Archived.", id: vendor.povId });
} catch (error) {
log.error({ err: error }, 'PurchaseOrderVendor archive failed');
return res.status(500).json({ message: "Error!", error: String(error) });
}
};

exports._internals = { IsMaster, GetCompanyId };
42 changes: 42 additions & 0 deletions app/models/purchaseordervendor.model.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 Aaron K. Clark
"use strict";

/**
* PurchaseOrderVendor — a vendor that POs are issued to. Has both a
* mailing address (where invoices arrive) and a billing address
* (where checks get sent). Direct company scoping via povCompId.
* Soft-deletes via povArch.
*/
module.exports = (sequelize, Sequelize) => {
const PurchaseOrderVendor = sequelize.define('PurchaseOrderVendor', {
povId: {
field: 'povId',
type: Sequelize.INTEGER,
autoIncrement: true,
primaryKey: true,
},
povName: { field: 'povName', type: Sequelize.TEXT, allowNull: false },
povMailingAddress1: { field: 'povMailingAddress1', type: Sequelize.TEXT, allowNull: false },
povMailingAddress2: { field: 'povMailingAddress2', type: Sequelize.TEXT },
povMailingCity: { field: 'povMailingCity', type: Sequelize.TEXT, allowNull: false },
povMailingState: { field: 'povMailingState', type: Sequelize.TEXT },
povMailingCountry: { field: 'povMailingCountry', type: Sequelize.TEXT },
povMailingZip: { field: 'povMailingZip', type: Sequelize.TEXT },
povBillingAddress1: { field: 'povBillingAddress1', type: Sequelize.TEXT },
povBillingAddress2: { field: 'povBillingAddress2', type: Sequelize.TEXT },
povBillingCity: { field: 'povBillingCity', type: Sequelize.TEXT },
povBillingState: { field: 'povBillingState', type: Sequelize.TEXT },
povBillingCountry: { field: 'povBillingCountry', type: Sequelize.TEXT },
povBillingZip: { field: 'povBillingZip', type: Sequelize.TEXT },
povPhone: { field: 'povPhone', type: Sequelize.TEXT },
povEMail: { field: 'povEMail', type: Sequelize.TEXT },
povCompId: { field: 'povCompId', type: Sequelize.INTEGER, allowNull: false },
povArch: { field: 'povArch', type: Sequelize.BOOLEAN, defaultValue: false },
}, {
tableName: 'PurchaseOrderVendors',
timestamps: false,
});

return PurchaseOrderVendor;
};
Loading
Loading