diff --git a/app/config/db.config.js b/app/config/db.config.js index b109100..1a42dc7 100644 --- a/app/config/db.config.js +++ b/app/config/db.config.js @@ -36,5 +36,6 @@ 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); +db.InventoryTransaction = require('../models/inventorytransaction.model.js')(sequelize, Sequelize); module.exports = db; diff --git a/app/config/openapi.js b/app/config/openapi.js index 0e2de3b..ab3844f 100644 --- a/app/config/openapi.js +++ b/app/config/openapi.js @@ -171,6 +171,17 @@ const versionInfoSchema = { }, }; +const inventoryTransactionSchema = { + type: 'object', + properties: { + invtId: { type: 'integer', readOnly: true }, + invtCompanyId: { type: 'integer' }, + invtDirection: { type: 'integer', enum: [0, 1], description: '0 = inbound (received), 1 = outbound (consumed)' }, + invtInitId: { type: 'integer', description: 'Inventory item this transaction affects (FK → InventoryItem.invitId)' }, + invtArch: { type: 'boolean', readOnly: true }, + }, +}; + const purchaseOrderHeaderSchema = { type: 'object', properties: { @@ -272,6 +283,7 @@ const spec = { PurchaseOrderVendor: purchaseOrderVendorSchema, PurchaseOrderHeader: purchaseOrderHeaderSchema, PurchaseOrderLine: purchaseOrderLineSchema, + InventoryTransaction: inventoryTransactionSchema, Error: errorResponse, }, }, @@ -781,6 +793,26 @@ const spec = { responses: { 200: { description: 'OK' }, 400: { description: 'Invalid header id' }, 403: { description: 'Auth failure' } }, }, }, + '/v1/inventorytransaction': { + post: { summary: 'Create an inventory transaction', security: [{ authKey: [] }], requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/InventoryTransaction' } } } }, responses: { 201: { description: 'Created' }, 400: { description: 'Bad request' }, 403: { description: 'Auth failure' } } }, + }, + '/v1/inventorytransaction/{id}': { + get: { summary: 'Get one inventory transaction', 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 an inventory transaction (unusual — reversing entries are the production pattern)', security: [{ authKey: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }], requestBody: { content: { 'application/json': { schema: { $ref: '#/components/schemas/InventoryTransaction' } } } }, responses: { 200: { description: 'Updated' }, 400: { description: 'No updatable fields supplied' }, 404: { description: 'Not found' }, 403: { description: 'Auth failure' } } }, + delete: { summary: 'Soft-delete an inventory transaction', 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/inventorytransaction/bycompany/{id}': { + get: { + summary: 'List inventory transactions in a company (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 company id' }, 403: { description: 'Auth failure' } }, + }, + }, }, }; diff --git a/app/controllers/inventorytransactioncontroller.js b/app/controllers/inventorytransactioncontroller.js new file mode 100644 index 0000000..0229945 --- /dev/null +++ b/app/controllers/inventorytransactioncontroller.js @@ -0,0 +1,220 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark +"use strict"; + +/** + * InventoryTransaction controller — direct compId scoping via + * invtCompanyId. Note that PATCH/DELETE on a movement log is unusual + * (production accounting systems prefer reversing entries); we expose + * them for surface parity, but operators may want to disable them at + * the reverse-proxy layer for audit-grade deployments. + */ + +const db = require('../config/db.config.js'); +const log = require('../config/logger.js'); +const auth = require('../middleware/auth.js'); +const InventoryTransaction = db.InventoryTransaction; + +const IsMaster = auth.isMaster; +const GetCompanyId = auth.getCompanyId; + +const ALLOWED_FIELDS_CREATE = ['invtDirection', 'invtInitId', 'invtCompanyId']; +const ALLOWED_FIELDS_UPDATE = ['invtDirection', 'invtInitId']; + +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) { + const authKeyCompanyId = await GetCompanyId(authKey); + if (authKeyCompanyId === -1) { + return res.status(403).json({ message: "Invalid Authorization Key." }); + } + if (payload.invtCompanyId !== undefined && Number(payload.invtCompanyId) !== authKeyCompanyId) { + return res.status(403).json({ + message: "Cannot create an inventory transaction for a company you do not belong to.", + }); + } + payload.invtCompanyId = authKeyCompanyId; + } else { + if (payload.invtCompanyId === undefined || Number(payload.invtCompanyId) <= 0) { + return res.status(400).json({ + message: "Master-key requests must specify invtCompanyId.", + }); + } + } + + payload.invtArch = false; + + try { + const created = await InventoryTransaction.create(payload); + return res.status(201).json({ message: "Inventory transaction created.", inventoryTransaction: created }); + } catch (error) { + log.error({ err: error }, 'InventoryTransaction.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 txn; + try { + txn = await InventoryTransaction.findByPk(req.params.id); + } catch (error) { + log.error({ err: error }, 'InventoryTransaction.findByPk failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } + if (!txn || txn.invtArch) { + return res.status(404).json({ message: "Not found." }); + } + + const isMaster = await IsMaster(authKey); + if (!isMaster) { + const companyId = await GetCompanyId(authKey); + if (companyId === -1 || txn.invtCompanyId !== companyId) { + return res.status(403).json({ message: "Invalid Authorization Key." }); + } + } + return res.status(200).json({ message: "Found.", inventoryTransaction: txn }); +}; + +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 InventoryTransaction.findAndCountAll({ + where: { invtCompanyId: targetCompanyId, invtArch: false }, + limit, offset, + order: [['invtId', 'DESC']], + }); + return res.status(200).json({ + message: "Successfully retrieved inventory transactions with CompanyId " + targetCompanyId, + count, limit, offset, inventoryTransactions: rows, + }); + } catch (error) { + log.error({ err: error }, 'InventoryTransaction.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 txn; + try { + txn = await InventoryTransaction.findByPk(req.params.id); + } catch (error) { + log.error({ err: error }, 'InventoryTransaction.findByPk failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } + if (!txn || txn.invtArch) { + return res.status(404).json({ message: "Not found." }); + } + + const isMaster = await IsMaster(authKey); + if (!isMaster) { + const companyId = await GetCompanyId(authKey); + if (companyId === -1 || txn.invtCompanyId !== 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 txn.update(updates); + return res.status(200).json({ message: "Updated.", inventoryTransaction: txn }); + } catch (error) { + log.error({ err: error }, 'InventoryTransaction.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 txn; + try { + txn = await InventoryTransaction.findByPk(req.params.id); + } catch (error) { + log.error({ err: error }, 'InventoryTransaction.findByPk failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } + if (!txn || txn.invtArch) { + return res.status(404).json({ message: "Not found." }); + } + + const isMaster = await IsMaster(authKey); + if (!isMaster) { + const companyId = await GetCompanyId(authKey); + if (companyId === -1 || txn.invtCompanyId !== companyId) { + return res.status(403).json({ message: "Invalid Authorization Key." }); + } + } + + try { + await txn.update({ invtArch: true }); + return res.status(200).json({ message: "Archived.", id: txn.invtId }); + } catch (error) { + log.error({ err: error }, 'InventoryTransaction archive failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } +}; + +exports._internals = { IsMaster, GetCompanyId }; diff --git a/app/models/inventorytransaction.model.js b/app/models/inventorytransaction.model.js new file mode 100644 index 0000000..4640909 --- /dev/null +++ b/app/models/inventorytransaction.model.js @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark +"use strict"; + +/** + * InventoryTransaction — movement log entry against an InventoryItem. + * `invtDirection`: 0 = inbound (received), 1 = outbound (consumed / + * sold). Direct company scoping via `invtCompanyId`. + * + * Note on column naming: the FK to InventoryItem.invitId is stored + * as `invtInitId` in the migration (BACPAC's "init" prefix carried + * through). We match what the migration creates rather than + * renaming. + */ +module.exports = (sequelize, Sequelize) => { + const InventoryTransaction = sequelize.define('InventoryTransaction', { + invtId: { + field: 'invtId', + type: Sequelize.INTEGER, + autoIncrement: true, + primaryKey: true, + }, + invtCompanyId: { field: 'invtCompanyId', type: Sequelize.INTEGER, allowNull: false }, + invtDirection: { field: 'invtDirection', type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 }, + invtArch: { field: 'invtArch', type: Sequelize.BOOLEAN, allowNull: false, defaultValue: false }, + invtInitId: { field: 'invtInitId', type: Sequelize.INTEGER, allowNull: false }, + }, { + tableName: 'InventoryTransactions', + timestamps: false, + }); + + return InventoryTransaction; +}; diff --git a/app/routers/router.js b/app/routers/router.js index a95544e..16d38e1 100644 --- a/app/routers/router.js +++ b/app/routers/router.js @@ -21,6 +21,7 @@ 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 inventoryTransaction = require('../controllers/inventorytransactioncontroller.js'); const openapiSpec = require('../config/openapi.js'); const v = require('../middleware/validate.js'); const customerSchemas = require('../schemas/customer.schema.js'); @@ -38,6 +39,7 @@ 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'); +const inventoryTransactionSchemas = require('../schemas/inventorytransaction.schema.js'); // Health / readiness probe. No auth required — only exposes liveness // of the API process and reachability of the database. @@ -475,4 +477,33 @@ router.delete( purchaseOrderLine.remove, ); +// v1 inventorytransaction routes. Direct compId scoping via invtCompanyId. +router.post( + '/v1/inventorytransaction', + v.body(inventoryTransactionSchemas.createBody), + inventoryTransaction.create, +); +router.get( + '/v1/inventorytransaction/bycompany/:id', + v.params(inventoryTransactionSchemas.intIdParam), + v.query(inventoryTransactionSchemas.listByCompanyQuery), + inventoryTransaction.listByCompany, +); +router.get( + '/v1/inventorytransaction/:id', + v.params(inventoryTransactionSchemas.intIdParam), + inventoryTransaction.getById, +); +router.patch( + '/v1/inventorytransaction/:id', + v.params(inventoryTransactionSchemas.intIdParam), + v.body(inventoryTransactionSchemas.updateBody), + inventoryTransaction.update, +); +router.delete( + '/v1/inventorytransaction/:id', + v.params(inventoryTransactionSchemas.intIdParam), + inventoryTransaction.remove, +); + module.exports = router; diff --git a/app/schemas/inventorytransaction.schema.js b/app/schemas/inventorytransaction.schema.js new file mode 100644 index 0000000..d7dbb0c --- /dev/null +++ b/app/schemas/inventorytransaction.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({ + invtDirection: z.union([z.literal(0), z.literal(1)]), + invtInitId: z.coerce.number().int().positive(), + invtCompanyId: z.coerce.number().int().positive().optional(), +}).strict({ + message: 'Unexpected field in body. Whitelist: invtDirection (0|1), invtInitId, invtCompanyId.', +}); + +// PATCH typically wouldn't be allowed on a financial movement log — +// reversing entries are the normal pattern. But we expose it for +// parity with the rest of the API surface. invtCompanyId is not +// patchable (would break auth invariants). +const updateBody = z.object({ + invtDirection: z.union([z.literal(0), z.literal(1)]).optional(), + invtInitId: z.coerce.number().int().positive().optional(), +}).strict({ + message: 'Unexpected field in body. Whitelist: invtDirection, invtInitId.', +}); + +const listByCompanyQuery = 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, + listByCompanyQuery, +}; diff --git a/tests/api/inventorytransaction.test.js b/tests/api/inventorytransaction.test.js new file mode 100644 index 0000000..436d74a --- /dev/null +++ b/tests/api/inventorytransaction.test.js @@ -0,0 +1,63 @@ +// 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: {}, + InventoryTransaction: { + findByPk: vi.fn().mockResolvedValue(null), + findAndCountAll: vi.fn().mockResolvedValue({ count: 0, rows: [] }), + create: vi.fn().mockResolvedValue({ invtId: 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('InventoryTransaction auth contract', () => { + test('GET 403 without authKey', async () => { expect((await request(app).get('/v1/inventorytransaction/1')).status).toBe(403); }); + test('POST 403 without authKey', async () => { + const res = await request(app).post('/v1/inventorytransaction').send({ invtDirection: 0, invtInitId: 1 }); + expect(res.status).toBe(403); + }); + test('GET /bycompany/:id 403 without authKey', async () => { expect((await request(app).get('/v1/inventorytransaction/bycompany/1')).status).toBe(403); }); + test('PATCH 403 without authKey', async () => { expect((await request(app).patch('/v1/inventorytransaction/1').send({ invtDirection: 1 })).status).toBe(403); }); + test('DELETE 403 without authKey', async () => { expect((await request(app).delete('/v1/inventorytransaction/1')).status).toBe(403); }); +}); + +describe('InventoryTransaction route mounting', () => { + test('routes mounted', async () => { + expect((await request(app).get('/v1/inventorytransaction/1').set('authKey', 'any')).status).not.toBe(404); + }); +}); + +describe('InventoryTransaction body validation', () => { + test('POST rejects unknown field', async () => { + const res = await request(app).post('/v1/inventorytransaction').set('authKey', 'any').send({ + invtDirection: 0, invtInitId: 1, bogus: 'no', + }); + expect(res.status).toBe(400); + }); + test('POST rejects invalid invtDirection (must be 0 or 1)', async () => { + const res = await request(app).post('/v1/inventorytransaction').set('authKey', 'any').send({ + invtDirection: 7, invtInitId: 1, + }); + expect(res.status).toBe(400); + }); +});