diff --git a/app/config/db.config.js b/app/config/db.config.js index 68a0fd4..b109100 100644 --- a/app/config/db.config.js +++ b/app/config/db.config.js @@ -34,5 +34,7 @@ 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); +db.PurchaseOrderHeader = require('../models/purchaseorderheader.model.js')(sequelize, Sequelize); +db.PurchaseOrderLine = require('../models/purchaseorderline.model.js')(sequelize, Sequelize); module.exports = db; diff --git a/app/config/openapi.js b/app/config/openapi.js index 2b8bcd0..0e2de3b 100644 --- a/app/config/openapi.js +++ b/app/config/openapi.js @@ -171,6 +171,31 @@ const versionInfoSchema = { }, }; +const purchaseOrderHeaderSchema = { + type: 'object', + properties: { + pohId: { type: 'integer', readOnly: true }, + pohDate: { type: 'string', format: 'date-time' }, + pohReference: { type: 'string' }, + pohTerms: { type: 'string' }, + pohPovId: { type: 'integer' }, + pohArch: { type: 'boolean', readOnly: true }, + }, +}; + +const purchaseOrderLineSchema = { + type: 'object', + properties: { + polId: { type: 'integer', readOnly: true }, + polpoh: { type: 'integer' }, + polItemDesc: { type: 'string' }, + polQty: { type: 'number' }, + polPrice: { type: 'number' }, + polInvtId: { type: 'integer' }, + polArch: { type: 'boolean', readOnly: true }, + }, +}; + const purchaseOrderVendorSchema = { type: 'object', properties: { @@ -245,6 +270,8 @@ const spec = { ProductEntry: productEntrySchema, VersionInfo: versionInfoSchema, PurchaseOrderVendor: purchaseOrderVendorSchema, + PurchaseOrderHeader: purchaseOrderHeaderSchema, + PurchaseOrderLine: purchaseOrderLineSchema, Error: errorResponse, }, }, @@ -714,6 +741,46 @@ const spec = { responses: { 200: { description: 'OK' }, 400: { description: 'Invalid company id' }, 403: { description: 'Auth failure' } }, }, }, + '/v1/purchaseorderheader': { + post: { summary: 'Create a PO header', security: [{ authKey: [] }], requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/PurchaseOrderHeader' } } } }, responses: { 201: { description: 'Created' }, 400: { description: 'Bad request' }, 403: { description: 'Auth failure' } } }, + }, + '/v1/purchaseorderheader/{id}': { + get: { summary: 'Get one PO header', 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 header', security: [{ authKey: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }], requestBody: { content: { 'application/json': { schema: { $ref: '#/components/schemas/PurchaseOrderHeader' } } } }, responses: { 200: { description: 'Updated' }, 400: { description: 'No updatable fields supplied' }, 404: { description: 'Not found' }, 403: { description: 'Auth failure' } } }, + delete: { summary: 'Soft-delete a PO header', 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/purchaseorderheader/byvendor/{id}': { + get: { + summary: 'List PO headers for a vendor (paginated, newest first)', + 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 vendor id' }, 403: { description: 'Auth failure' } }, + }, + }, + '/v1/purchaseorderline': { + post: { summary: 'Create a PO line', security: [{ authKey: [] }], requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/PurchaseOrderLine' } } } }, responses: { 201: { description: 'Created' }, 400: { description: 'Bad request' }, 403: { description: 'Auth failure' } } }, + }, + '/v1/purchaseorderline/{id}': { + get: { summary: 'Get one PO line', 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 line', security: [{ authKey: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }], requestBody: { content: { 'application/json': { schema: { $ref: '#/components/schemas/PurchaseOrderLine' } } } }, responses: { 200: { description: 'Updated' }, 400: { description: 'No updatable fields supplied' }, 404: { description: 'Not found' }, 403: { description: 'Auth failure' } } }, + delete: { summary: 'Soft-delete a PO line', 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/purchaseorderline/byheader/{id}': { + get: { + summary: 'List PO lines for a header (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 header id' }, 403: { description: 'Auth failure' } }, + }, + }, }, }; diff --git a/app/controllers/purchaseorderheadercontroller.js b/app/controllers/purchaseorderheadercontroller.js new file mode 100644 index 0000000..549c1c7 --- /dev/null +++ b/app/controllers/purchaseorderheadercontroller.js @@ -0,0 +1,209 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark +"use strict"; + +/** + * PurchaseOrderHeader controller — vendor-scoped auth via + * pohPovId → PurchaseOrderVendor.povCompId. + */ + +const db = require('../config/db.config.js'); +const log = require('../config/logger.js'); +const auth = require('../middleware/auth.js'); +const PurchaseOrderHeader = db.PurchaseOrderHeader; + +const IsMaster = auth.isMaster; +const GetCompanyId = auth.getCompanyId; +const GetCompanyIdByPovId = auth.getCompanyIdByPovId; + +const ALLOWED_FIELDS_CREATE = ['pohDate', 'pohReference', 'pohTerms', 'pohPovId']; +const ALLOWED_FIELDS_UPDATE = ['pohDate', 'pohReference', 'pohTerms']; + +exports.create = async (req, res) => { + const authKey = req.get('authKey'); + if (!authKey) { + return res.status(403).json({ message: "Authorization key not sent." }); + } + + const body = req.body || {}; + const payload = {}; + for (const f of ALLOWED_FIELDS_CREATE) { + if (body[f] !== undefined) payload[f] = body[f]; + } + if (!payload.pohPovId) { + return res.status(400).json({ message: "pohPovId is required." }); + } + + const isMaster = await IsMaster(authKey); + if (!isMaster) { + const authCompanyId = await GetCompanyId(authKey); + const vendorCompanyId = await GetCompanyIdByPovId(payload.pohPovId); + if (authCompanyId === -1 || vendorCompanyId === -1 || authCompanyId !== vendorCompanyId) { + return res.status(403).json({ + message: "Cannot create a purchase order for a vendor in a company you do not belong to.", + }); + } + } + + payload.pohArch = false; + + try { + const created = await PurchaseOrderHeader.create(payload); + return res.status(201).json({ message: "Purchase order header created.", purchaseOrderHeader: created }); + } catch (error) { + log.error({ err: error }, 'PurchaseOrderHeader.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 header; + try { + header = await PurchaseOrderHeader.findByPk(req.params.id); + } catch (error) { + log.error({ err: error }, 'PurchaseOrderHeader.findByPk failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } + if (!header || header.pohArch) { + return res.status(404).json({ message: "Not found." }); + } + + const isMaster = await IsMaster(authKey); + if (!isMaster) { + const authCompanyId = await GetCompanyId(authKey); + const vendorCompanyId = await GetCompanyIdByPovId(header.pohPovId); + if (authCompanyId === -1 || vendorCompanyId === -1 || authCompanyId !== vendorCompanyId) { + return res.status(403).json({ message: "Invalid Authorization Key." }); + } + } + return res.status(200).json({ message: "Found.", purchaseOrderHeader: header }); +}; + +exports.listByVendor = async (req, res) => { + const authKey = req.get('authKey'); + if (!authKey) { + return res.status(403).json({ message: "Authorization key not sent." }); + } + + const targetVendorId = Number(req.params.id); + if (!Number.isInteger(targetVendorId) || targetVendorId <= 0) { + return res.status(400).json({ message: "Invalid vendor id." }); + } + + const isMaster = await IsMaster(authKey); + if (!isMaster) { + const authCompanyId = await GetCompanyId(authKey); + const vendorCompanyId = await GetCompanyIdByPovId(targetVendorId); + if (authCompanyId === -1 || vendorCompanyId === -1 || authCompanyId !== vendorCompanyId) { + 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 PurchaseOrderHeader.findAndCountAll({ + where: { pohPovId: targetVendorId, pohArch: false }, + limit, offset, + order: [['pohDate', 'DESC']], + }); + return res.status(200).json({ + message: "Successfully retrieved purchase orders for VendorId " + targetVendorId, + count, limit, offset, purchaseOrderHeaders: rows, + }); + } catch (error) { + log.error({ err: error }, 'PurchaseOrderHeader.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 header; + try { + header = await PurchaseOrderHeader.findByPk(req.params.id); + } catch (error) { + log.error({ err: error }, 'PurchaseOrderHeader.findByPk failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } + if (!header || header.pohArch) { + return res.status(404).json({ message: "Not found." }); + } + + const isMaster = await IsMaster(authKey); + if (!isMaster) { + const authCompanyId = await GetCompanyId(authKey); + const vendorCompanyId = await GetCompanyIdByPovId(header.pohPovId); + if (authCompanyId === -1 || vendorCompanyId === -1 || authCompanyId !== vendorCompanyId) { + 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 header.update(updates); + return res.status(200).json({ message: "Updated.", purchaseOrderHeader: header }); + } catch (error) { + log.error({ err: error }, 'PurchaseOrderHeader.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 header; + try { + header = await PurchaseOrderHeader.findByPk(req.params.id); + } catch (error) { + log.error({ err: error }, 'PurchaseOrderHeader.findByPk failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } + if (!header || header.pohArch) { + return res.status(404).json({ message: "Not found." }); + } + + const isMaster = await IsMaster(authKey); + if (!isMaster) { + const authCompanyId = await GetCompanyId(authKey); + const vendorCompanyId = await GetCompanyIdByPovId(header.pohPovId); + if (authCompanyId === -1 || vendorCompanyId === -1 || authCompanyId !== vendorCompanyId) { + return res.status(403).json({ message: "Invalid Authorization Key." }); + } + } + + try { + await header.update({ pohArch: true }); + return res.status(200).json({ message: "Archived.", id: header.pohId }); + } catch (error) { + log.error({ err: error }, 'PurchaseOrderHeader archive failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } +}; + +exports._internals = { IsMaster, GetCompanyId, GetCompanyIdByPovId }; diff --git a/app/controllers/purchaseorderlinecontroller.js b/app/controllers/purchaseorderlinecontroller.js new file mode 100644 index 0000000..0cf5482 --- /dev/null +++ b/app/controllers/purchaseorderlinecontroller.js @@ -0,0 +1,209 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark +"use strict"; + +/** + * PurchaseOrderLine controller — header-scoped auth via + * polpoh → PurchaseOrderHeader → vendor.povCompId. + */ + +const db = require('../config/db.config.js'); +const log = require('../config/logger.js'); +const auth = require('../middleware/auth.js'); +const PurchaseOrderLine = db.PurchaseOrderLine; + +const IsMaster = auth.isMaster; +const GetCompanyId = auth.getCompanyId; +const GetCompanyIdByPohId = auth.getCompanyIdByPohId; + +const ALLOWED_FIELDS_CREATE = ['polpoh', 'polItemDesc', 'polQty', 'polPrice', 'polInvtId']; +const ALLOWED_FIELDS_UPDATE = ['polItemDesc', 'polQty', 'polPrice', 'polInvtId']; + +exports.create = async (req, res) => { + const authKey = req.get('authKey'); + if (!authKey) { + return res.status(403).json({ message: "Authorization key not sent." }); + } + + const body = req.body || {}; + const payload = {}; + for (const f of ALLOWED_FIELDS_CREATE) { + if (body[f] !== undefined) payload[f] = body[f]; + } + if (!payload.polpoh) { + return res.status(400).json({ message: "polpoh (PO header id) is required." }); + } + + const isMaster = await IsMaster(authKey); + if (!isMaster) { + const authCompanyId = await GetCompanyId(authKey); + const headerCompanyId = await GetCompanyIdByPohId(payload.polpoh); + if (authCompanyId === -1 || headerCompanyId === -1 || authCompanyId !== headerCompanyId) { + return res.status(403).json({ + message: "Cannot create a PO line for a header in a company you do not belong to.", + }); + } + } + + payload.polArch = false; + + try { + const created = await PurchaseOrderLine.create(payload); + return res.status(201).json({ message: "Purchase order line created.", purchaseOrderLine: created }); + } catch (error) { + log.error({ err: error }, 'PurchaseOrderLine.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 line; + try { + line = await PurchaseOrderLine.findByPk(req.params.id); + } catch (error) { + log.error({ err: error }, 'PurchaseOrderLine.findByPk failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } + if (!line || line.polArch) { + return res.status(404).json({ message: "Not found." }); + } + + const isMaster = await IsMaster(authKey); + if (!isMaster) { + const authCompanyId = await GetCompanyId(authKey); + const headerCompanyId = await GetCompanyIdByPohId(line.polpoh); + if (authCompanyId === -1 || headerCompanyId === -1 || authCompanyId !== headerCompanyId) { + return res.status(403).json({ message: "Invalid Authorization Key." }); + } + } + return res.status(200).json({ message: "Found.", purchaseOrderLine: line }); +}; + +exports.listByHeader = async (req, res) => { + const authKey = req.get('authKey'); + if (!authKey) { + return res.status(403).json({ message: "Authorization key not sent." }); + } + + const targetHeaderId = Number(req.params.id); + if (!Number.isInteger(targetHeaderId) || targetHeaderId <= 0) { + return res.status(400).json({ message: "Invalid header id." }); + } + + const isMaster = await IsMaster(authKey); + if (!isMaster) { + const authCompanyId = await GetCompanyId(authKey); + const headerCompanyId = await GetCompanyIdByPohId(targetHeaderId); + if (authCompanyId === -1 || headerCompanyId === -1 || authCompanyId !== headerCompanyId) { + 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 PurchaseOrderLine.findAndCountAll({ + where: { polpoh: targetHeaderId, polArch: false }, + limit, offset, + order: [['polId', 'ASC']], + }); + return res.status(200).json({ + message: "Successfully retrieved PO lines for HeaderId " + targetHeaderId, + count, limit, offset, purchaseOrderLines: rows, + }); + } catch (error) { + log.error({ err: error }, 'PurchaseOrderLine.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 line; + try { + line = await PurchaseOrderLine.findByPk(req.params.id); + } catch (error) { + log.error({ err: error }, 'PurchaseOrderLine.findByPk failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } + if (!line || line.polArch) { + return res.status(404).json({ message: "Not found." }); + } + + const isMaster = await IsMaster(authKey); + if (!isMaster) { + const authCompanyId = await GetCompanyId(authKey); + const headerCompanyId = await GetCompanyIdByPohId(line.polpoh); + if (authCompanyId === -1 || headerCompanyId === -1 || authCompanyId !== headerCompanyId) { + 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 line.update(updates); + return res.status(200).json({ message: "Updated.", purchaseOrderLine: line }); + } catch (error) { + log.error({ err: error }, 'PurchaseOrderLine.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 line; + try { + line = await PurchaseOrderLine.findByPk(req.params.id); + } catch (error) { + log.error({ err: error }, 'PurchaseOrderLine.findByPk failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } + if (!line || line.polArch) { + return res.status(404).json({ message: "Not found." }); + } + + const isMaster = await IsMaster(authKey); + if (!isMaster) { + const authCompanyId = await GetCompanyId(authKey); + const headerCompanyId = await GetCompanyIdByPohId(line.polpoh); + if (authCompanyId === -1 || headerCompanyId === -1 || authCompanyId !== headerCompanyId) { + return res.status(403).json({ message: "Invalid Authorization Key." }); + } + } + + try { + await line.update({ polArch: true }); + return res.status(200).json({ message: "Archived.", id: line.polId }); + } catch (error) { + log.error({ err: error }, 'PurchaseOrderLine archive failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } +}; + +exports._internals = { IsMaster, GetCompanyId, GetCompanyIdByPohId }; diff --git a/app/middleware/auth.js b/app/middleware/auth.js index 7f26256..4b6bedf 100644 --- a/app/middleware/auth.js +++ b/app/middleware/auth.js @@ -87,6 +87,54 @@ async function getCompanyIdByCustomerId(customerId) { } } +/** + * Resolve a PO vendor id to its owning company id. Used by + * PurchaseOrderHeader to scope auth — headers reference a vendor + * (pohPovId), and the vendor's povCompId is the auth boundary. + */ +async function getCompanyIdByPovId(povId) { + const idStr = povId == null ? '' : String(povId); + if (idStr.length === 0 || idStr === '0') return -1; + try { + const r = await db.sequelize.query( + 'SELECT "povCompId" FROM "dbo"."PurchaseOrderVendors" WHERE "povId" = ? AND "povArch" = false;', + { replacements: [povId], type: sequelize.QueryTypes.SELECT }, + ); + if (!r || r.length === 0) return -1; + const cid = r[0].povCompId; + return typeof cid === 'number' && cid > 0 ? cid : -1; + } catch (error) { + log.error({ err: error }, 'auth.getCompanyIdByPovId query failed'); + return -1; + } +} + +/** + * Resolve a PO header id to its owning company id. Used by + * PurchaseOrderLine — lines reference a header (polpoh), and the + * header references a vendor (pohPovId), and the vendor's povCompId + * is the auth boundary. Single query via JOIN keeps the lookup cheap. + */ +async function getCompanyIdByPohId(pohId) { + const idStr = pohId == null ? '' : String(pohId); + if (idStr.length === 0 || idStr === '0') return -1; + try { + const r = await db.sequelize.query( + `SELECT v."povCompId" + FROM "dbo"."PurchaseOrderHeaders" h + JOIN "dbo"."PurchaseOrderVendors" v ON v."povId" = h."pohPovId" + WHERE h."pohId" = ? AND h."pohArch" = false AND v."povArch" = false;`, + { replacements: [pohId], type: sequelize.QueryTypes.SELECT }, + ); + if (!r || r.length === 0) return -1; + const cid = r[0].povCompId; + return typeof cid === 'number' && cid > 0 ? cid : -1; + } catch (error) { + log.error({ err: error }, 'auth.getCompanyIdByPohId query failed'); + return -1; + } +} + /** * Resolve a job id to its owning company id. * @@ -168,6 +216,8 @@ module.exports = { getCompanyId, getCompanyIdByCustomerId, getCompanyIdByJobId, + getCompanyIdByPovId, + getCompanyIdByPohId, requireAuthKey, resolveAuth, }; diff --git a/app/models/purchaseorderheader.model.js b/app/models/purchaseorderheader.model.js new file mode 100644 index 0000000..d6c753b --- /dev/null +++ b/app/models/purchaseorderheader.model.js @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark +"use strict"; + +/** + * PurchaseOrderHeader — one row per PO. Auth scopes through + * pohPovId → PurchaseOrderVendor.povCompId (see + * auth.getCompanyIdByPovId). + */ +module.exports = (sequelize, Sequelize) => { + const PurchaseOrderHeader = sequelize.define('PurchaseOrderHeader', { + pohId: { + field: 'pohId', + type: Sequelize.INTEGER, + autoIncrement: true, + primaryKey: true, + }, + pohDate: { field: 'pohDate', type: Sequelize.DATE, allowNull: false }, + pohReference: { field: 'pohReference', type: Sequelize.TEXT, allowNull: false }, + pohTerms: { field: 'pohTerms', type: Sequelize.TEXT, allowNull: false }, + pohPovId: { field: 'pohPovId', type: Sequelize.INTEGER, allowNull: false }, + pohArch: { field: 'pohArch', type: Sequelize.BOOLEAN, defaultValue: false }, + }, { + tableName: 'PurchaseOrderHeaders', + timestamps: false, + }); + + return PurchaseOrderHeader; +}; diff --git a/app/models/purchaseorderline.model.js b/app/models/purchaseorderline.model.js new file mode 100644 index 0000000..69f3f32 --- /dev/null +++ b/app/models/purchaseorderline.model.js @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark +"use strict"; + +/** + * PurchaseOrderLine — a line item on a PurchaseOrderHeader. Auth + * scopes through polpoh → PurchaseOrderHeader → vendor.povCompId + * (see auth.getCompanyIdByPohId). + * + * Note: the FK column is named "polpoh" in the BACPAC and PG + * schema — lowercase, no separator. We match the existing column + * name rather than rename to "polPohId" because that's what + * setup/TimeTracker.sql + the migration actually create. + */ +module.exports = (sequelize, Sequelize) => { + const PurchaseOrderLine = sequelize.define('PurchaseOrderLine', { + polId: { + field: 'polId', + type: Sequelize.INTEGER, + autoIncrement: true, + primaryKey: true, + }, + polpoh: { field: 'polpoh', type: Sequelize.INTEGER, allowNull: false }, + polItemDesc: { field: 'polItemDesc', type: Sequelize.TEXT, allowNull: false }, + polQty: { field: 'polQty', type: Sequelize.DOUBLE, allowNull: false }, + polPrice: { field: 'polPrice', type: Sequelize.DOUBLE, allowNull: false }, + polInvtId: { field: 'polInvtId', type: Sequelize.INTEGER, allowNull: false }, + polArch: { field: 'polArch', type: Sequelize.BOOLEAN, defaultValue: false }, + }, { + tableName: 'PurchaseOrderLines', + timestamps: false, + }); + + return PurchaseOrderLine; +}; diff --git a/app/routers/router.js b/app/routers/router.js index b16c716..a95544e 100644 --- a/app/routers/router.js +++ b/app/routers/router.js @@ -19,6 +19,8 @@ const invoiceJob = require('../controllers/invoicejobcontroller.js'); const productEntry = require('../controllers/productentrycontroller.js'); const versionInfo = require('../controllers/versioninfocontroller.js'); const purchaseOrderVendor = require('../controllers/purchaseordervendorcontroller.js'); +const purchaseOrderHeader = require('../controllers/purchaseorderheadercontroller.js'); +const purchaseOrderLine = require('../controllers/purchaseorderlinecontroller.js'); const openapiSpec = require('../config/openapi.js'); const v = require('../middleware/validate.js'); const customerSchemas = require('../schemas/customer.schema.js'); @@ -34,6 +36,8 @@ const invoiceJobSchemas = require('../schemas/invoicejob.schema.js'); const productEntrySchemas = require('../schemas/productentry.schema.js'); const versionInfoSchemas = require('../schemas/versioninfo.schema.js'); const purchaseOrderVendorSchemas = require('../schemas/purchaseordervendor.schema.js'); +const purchaseOrderHeaderSchemas = require('../schemas/purchaseorderheader.schema.js'); +const purchaseOrderLineSchemas = require('../schemas/purchaseorderline.schema.js'); // Health / readiness probe. No auth required — only exposes liveness // of the API process and reachability of the database. @@ -413,4 +417,62 @@ router.delete( purchaseOrderVendor.remove, ); +// v1 purchaseorderheader routes. Vendor-scoped via pohPovId → povCompId. +router.post( + '/v1/purchaseorderheader', + v.body(purchaseOrderHeaderSchemas.createBody), + purchaseOrderHeader.create, +); +router.get( + '/v1/purchaseorderheader/byvendor/:id', + v.params(purchaseOrderHeaderSchemas.intIdParam), + v.query(purchaseOrderHeaderSchemas.listByVendorQuery), + purchaseOrderHeader.listByVendor, +); +router.get( + '/v1/purchaseorderheader/:id', + v.params(purchaseOrderHeaderSchemas.intIdParam), + purchaseOrderHeader.getById, +); +router.patch( + '/v1/purchaseorderheader/:id', + v.params(purchaseOrderHeaderSchemas.intIdParam), + v.body(purchaseOrderHeaderSchemas.updateBody), + purchaseOrderHeader.update, +); +router.delete( + '/v1/purchaseorderheader/:id', + v.params(purchaseOrderHeaderSchemas.intIdParam), + purchaseOrderHeader.remove, +); + +// v1 purchaseorderline routes. Header-scoped via polpoh → header → vendor.povCompId. +router.post( + '/v1/purchaseorderline', + v.body(purchaseOrderLineSchemas.createBody), + purchaseOrderLine.create, +); +router.get( + '/v1/purchaseorderline/byheader/:id', + v.params(purchaseOrderLineSchemas.intIdParam), + v.query(purchaseOrderLineSchemas.listByHeaderQuery), + purchaseOrderLine.listByHeader, +); +router.get( + '/v1/purchaseorderline/:id', + v.params(purchaseOrderLineSchemas.intIdParam), + purchaseOrderLine.getById, +); +router.patch( + '/v1/purchaseorderline/:id', + v.params(purchaseOrderLineSchemas.intIdParam), + v.body(purchaseOrderLineSchemas.updateBody), + purchaseOrderLine.update, +); +router.delete( + '/v1/purchaseorderline/:id', + v.params(purchaseOrderLineSchemas.intIdParam), + purchaseOrderLine.remove, +); + module.exports = router; diff --git a/app/schemas/purchaseorderheader.schema.js b/app/schemas/purchaseorderheader.schema.js new file mode 100644 index 0000000..3cf9af4 --- /dev/null +++ b/app/schemas/purchaseorderheader.schema.js @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark +"use strict"; + +const { z } = require('zod'); + +const intIdParam = z.object({ + id: z.coerce.number().int().positive(), +}); + +const isoDatetime = z.string().datetime({ + offset: true, + message: 'Must be an ISO 8601 datetime.', +}); + +const createBody = z.object({ + pohDate: isoDatetime, + pohReference: z.string().min(1).max(255), + pohTerms: z.string().min(1).max(1000), + pohPovId: z.coerce.number().int().positive(), +}).strict({ + message: 'Unexpected field in body. Whitelist: pohDate, pohReference, pohTerms, pohPovId.', +}); + +const updateBody = z.object({ + pohDate: isoDatetime.optional(), + pohReference: z.string().min(1).max(255).optional(), + pohTerms: z.string().min(1).max(1000).optional(), +}).strict({ + message: 'Unexpected field in body. Whitelist: pohDate, pohReference, pohTerms.', +}); + +const listByVendorQuery = z.object({ + limit: z.coerce.number().int().positive().max(500).optional(), + offset: z.coerce.number().int().nonnegative().optional(), +}).strict({ + message: 'Unexpected query parameter. Allowed: limit, offset.', +}); + +module.exports = { + intIdParam, + createBody, + updateBody, + listByVendorQuery, +}; diff --git a/app/schemas/purchaseorderline.schema.js b/app/schemas/purchaseorderline.schema.js new file mode 100644 index 0000000..947f086 --- /dev/null +++ b/app/schemas/purchaseorderline.schema.js @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark +"use strict"; + +const { z } = require('zod'); + +const intIdParam = z.object({ + id: z.coerce.number().int().positive(), +}); + +const createBody = z.object({ + polpoh: z.coerce.number().int().positive(), + polItemDesc: z.string().min(1).max(1000), + polQty: z.coerce.number(), + polPrice: z.coerce.number(), + polInvtId: z.coerce.number().int().positive(), +}).strict({ + message: 'Unexpected field in body. Whitelist: polpoh, polItemDesc, polQty, polPrice, polInvtId.', +}); + +const updateBody = z.object({ + polItemDesc: z.string().min(1).max(1000).optional(), + polQty: z.coerce.number().optional(), + polPrice: z.coerce.number().optional(), + polInvtId: z.coerce.number().int().positive().optional(), +}).strict({ + message: 'Unexpected field in body. Whitelist: polItemDesc, polQty, polPrice, polInvtId.', +}); + +const listByHeaderQuery = z.object({ + limit: z.coerce.number().int().positive().max(500).optional(), + offset: z.coerce.number().int().nonnegative().optional(), +}).strict({ + message: 'Unexpected query parameter. Allowed: limit, offset.', +}); + +module.exports = { + intIdParam, + createBody, + updateBody, + listByHeaderQuery, +}; diff --git a/tests/api/purchaseorderheader.test.js b/tests/api/purchaseorderheader.test.js new file mode 100644 index 0000000..8370e9a --- /dev/null +++ b/tests/api/purchaseorderheader.test.js @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark + +import { describe, test, expect, vi, beforeAll } from 'vitest'; +import request from 'supertest'; +import express from 'express'; + +vi.mock('../../app/config/db.config.js', () => ({ + sequelize: { query: vi.fn().mockResolvedValue([]), QueryTypes: { SELECT: 'SELECT' } }, + Sequelize: {}, + Customer: {}, TimeEntry: {}, Worker: {}, BillingType: {}, InventoryItem: {}, + Company: {}, Job: {}, Invoice: {}, CustomerPayment: {}, + InvoiceJob: {}, ProductEntry: {}, VersionInfo: {}, + PurchaseOrderVendor: {}, + PurchaseOrderHeader: { + findByPk: vi.fn().mockResolvedValue(null), + findAndCountAll: vi.fn().mockResolvedValue({ count: 0, rows: [] }), + create: vi.fn().mockResolvedValue({ pohId: 1 }), + }, + PurchaseOrderLine: {}, + ApiKey: {}, ApiMaster: {}, +})); + +let app; + +beforeAll(async () => { + const router = (await import('../../app/routers/router.js')).default + || require('../../app/routers/router.js'); + app = express(); + app.use(express.json()); + app.use('/', router); +}); + +describe('PurchaseOrderHeader auth contract', () => { + test('GET 403 without authKey', async () => { expect((await request(app).get('/v1/purchaseorderheader/1')).status).toBe(403); }); + test('POST 403 without authKey', async () => { + const res = await request(app).post('/v1/purchaseorderheader').send({ + pohDate: '2026-05-17T00:00:00Z', pohReference: 'PO-1', pohTerms: 'NET30', pohPovId: 1, + }); + expect(res.status).toBe(403); + }); + test('GET /byvendor/:id 403 without authKey', async () => { expect((await request(app).get('/v1/purchaseorderheader/byvendor/1')).status).toBe(403); }); + test('PATCH 403 without authKey', async () => { expect((await request(app).patch('/v1/purchaseorderheader/1').send({ pohReference: 'x' })).status).toBe(403); }); + test('DELETE 403 without authKey', async () => { expect((await request(app).delete('/v1/purchaseorderheader/1')).status).toBe(403); }); +}); + +describe('PurchaseOrderHeader route mounting', () => { + test('routes mounted', async () => { + expect((await request(app).get('/v1/purchaseorderheader/1').set('authKey', 'any')).status).not.toBe(404); + }); +}); + +describe('PurchaseOrderHeader body validation', () => { + test('POST rejects unknown field', async () => { + const res = await request(app).post('/v1/purchaseorderheader').set('authKey', 'any').send({ + pohDate: '2026-05-17T00:00:00Z', pohReference: 'PO-1', pohTerms: 'NET30', pohPovId: 1, bogus: 'no', + }); + expect(res.status).toBe(400); + }); + test('POST rejects bad datetime', async () => { + const res = await request(app).post('/v1/purchaseorderheader').set('authKey', 'any').send({ + pohDate: 'tomorrow', pohReference: 'PO-1', pohTerms: 'NET30', pohPovId: 1, + }); + expect(res.status).toBe(400); + }); +}); diff --git a/tests/api/purchaseorderline.test.js b/tests/api/purchaseorderline.test.js new file mode 100644 index 0000000..3387965 --- /dev/null +++ b/tests/api/purchaseorderline.test.js @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark + +import { describe, test, expect, vi, beforeAll } from 'vitest'; +import request from 'supertest'; +import express from 'express'; + +vi.mock('../../app/config/db.config.js', () => ({ + sequelize: { query: vi.fn().mockResolvedValue([]), QueryTypes: { SELECT: 'SELECT' } }, + Sequelize: {}, + Customer: {}, TimeEntry: {}, Worker: {}, BillingType: {}, InventoryItem: {}, + Company: {}, Job: {}, Invoice: {}, CustomerPayment: {}, + InvoiceJob: {}, ProductEntry: {}, VersionInfo: {}, + PurchaseOrderVendor: {}, PurchaseOrderHeader: {}, + PurchaseOrderLine: { + findByPk: vi.fn().mockResolvedValue(null), + findAndCountAll: vi.fn().mockResolvedValue({ count: 0, rows: [] }), + create: vi.fn().mockResolvedValue({ polId: 1 }), + }, + ApiKey: {}, ApiMaster: {}, +})); + +let app; + +beforeAll(async () => { + const router = (await import('../../app/routers/router.js')).default + || require('../../app/routers/router.js'); + app = express(); + app.use(express.json()); + app.use('/', router); +}); + +describe('PurchaseOrderLine auth contract', () => { + test('GET 403 without authKey', async () => { expect((await request(app).get('/v1/purchaseorderline/1')).status).toBe(403); }); + test('POST 403 without authKey', async () => { + const res = await request(app).post('/v1/purchaseorderline').send({ + polpoh: 1, polItemDesc: 'Widget', polQty: 10, polPrice: 5, polInvtId: 1, + }); + expect(res.status).toBe(403); + }); + test('GET /byheader/:id 403 without authKey', async () => { expect((await request(app).get('/v1/purchaseorderline/byheader/1')).status).toBe(403); }); + test('PATCH 403 without authKey', async () => { expect((await request(app).patch('/v1/purchaseorderline/1').send({ polQty: 5 })).status).toBe(403); }); + test('DELETE 403 without authKey', async () => { expect((await request(app).delete('/v1/purchaseorderline/1')).status).toBe(403); }); +}); + +describe('PurchaseOrderLine route mounting', () => { + test('routes mounted', async () => { + expect((await request(app).get('/v1/purchaseorderline/1').set('authKey', 'any')).status).not.toBe(404); + }); +}); + +describe('PurchaseOrderLine body validation', () => { + test('POST rejects unknown field', async () => { + const res = await request(app).post('/v1/purchaseorderline').set('authKey', 'any').send({ + polpoh: 1, polItemDesc: 'Widget', polQty: 10, polPrice: 5, polInvtId: 1, bogus: 'no', + }); + expect(res.status).toBe(400); + }); + test('POST rejects missing polItemDesc', async () => { + const res = await request(app).post('/v1/purchaseorderline').set('authKey', 'any').send({ + polpoh: 1, polQty: 10, polPrice: 5, polInvtId: 1, + }); + expect(res.status).toBe(400); + }); +});