diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d63473..33061c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Bulk-create endpoints for 5 direct-compId entities** (P3-H). + New `POST /v1//bulk` on Worker, BillingType, InventoryItem, + InventoryTransaction, and PurchaseOrderVendor. Same shape as the + existing `POST /v1/customer/bulk`: 500-entry cap, zod-strict + whitelist, transactional all-or-nothing insert, master vs. + non-master scoping enforced per entry. Shared + `app/controllers/_bulk-helpers.js#makeBulkCreate` factory removes + ~150 lines of would-be duplication; Customer's pre-existing handler + keeps its bespoke logic until a follow-up unifies them. - **Idempotency-Key support on POST routes** (P3-G). Clients may send an `Idempotency-Key: ` header on any POST under `/v1/*`. The first response (status + diff --git a/app/controllers/_bulk-helpers.js b/app/controllers/_bulk-helpers.js new file mode 100644 index 0000000..06f4db6 --- /dev/null +++ b/app/controllers/_bulk-helpers.js @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark +"use strict"; + +/** + * Shared factory for bulk-create controllers on entities that scope + * directly to a single company via a *CompId column. Customer's + * bulkCreate predates this helper and has the same shape baked in; + * we don't migrate it here to keep the diff focused on the new + * endpoints (P3-H), but a follow-up should consolidate them. + * + * What this factory replaces: 5 near-identical controllers + * (worker/billingtype/inventoryitem/inventorytransaction/ + * purchaseordervendor) each repeating the same auth-scope-loop- + * transaction-bulkCreate-handle-error scaffold. + * + * What varies between entities — passed as config: + * - Model the sequelize model (db.Worker etc.) + * - modelKey string label for logs ("Worker", "BillingType") + * - compIdField the company-scope column ("workerCompId", "btCompId", + * "invitCompId", "invtCompanyId", "povCompId") + * - allowedFields whitelist for each entry (the same list the + * single-create endpoint accepts, minus the *Arch + * column which the controller sets to false) + * - archField soft-delete column ("workerArch", "btArch", etc.) + * - bodyKey JSON key the array hangs off ("workers", + * "billingTypes", etc.) — matches the zod schema's + * outer key. + * - createdKey response key for the inserted rows ("workers", ...) + */ + +const db = require('../config/db.config.js'); +const log = require('../config/logger.js'); +const auth = require('../middleware/auth.js'); + +function makeBulkCreate({ + Model, + modelKey, + compIdField, + allowedFields, + archField, + bodyKey, + createdKey, +}) { + return async function bulkCreate(req, res) { + const authKey = req.get('authKey'); + if (!authKey) { + return res.status(403).json({ message: "Authorization key not sent." }); + } + + let isAuthKeyMasterKey; + try { + isAuthKeyMasterKey = await auth.isMaster(authKey); + } catch (error) { + log.error({ err: error }, `${modelKey}: isMaster failed`); + return res.status(500).json({ message: "Error!", error: String(error) }); + } + + const input = (req.body && Array.isArray(req.body[bodyKey])) + ? req.body[bodyKey] + : []; + if (input.length === 0) { + return res.status(400).json({ message: `${bodyKey} array is required and must be non-empty.` }); + } + + // Resolve authKey's company once for non-master path. + let authKeyCompanyId = null; + if (!isAuthKeyMasterKey) { + try { + authKeyCompanyId = await auth.getCompanyId(authKey); + } catch (error) { + log.error({ err: error }, `${modelKey}: getCompanyId failed`); + return res.status(500).json({ message: "Error!", error: String(error) }); + } + if (authKeyCompanyId === -1) { + return res.status(403).json({ message: "Invalid Authorization Key." }); + } + } + + // Whitelist + auth-scope each entry. + const payloads = []; + for (let i = 0; i < input.length; i += 1) { + const entry = input[i] || {}; + const p = {}; + for (const f of allowedFields) { + if (entry[f] !== undefined) p[f] = entry[f]; + } + if (isAuthKeyMasterKey) { + if (p[compIdField] === undefined || Number(p[compIdField]) <= 0) { + return res.status(400).json({ + message: `${bodyKey}[${i}]: master-key requests must specify ${compIdField}.`, + }); + } + } else { + if (p[compIdField] !== undefined && Number(p[compIdField]) !== authKeyCompanyId) { + return res.status(403).json({ + message: `${bodyKey}[${i}]: cannot create for a company you do not belong to.`, + }); + } + p[compIdField] = authKeyCompanyId; + } + // archField intentionally defaulted to false here so + // partially-archived bulk inserts can't be smuggled in. + p[archField] = false; + payloads.push(p); + } + + const t = await db.sequelize.transaction(); + try { + const created = await Model.bulkCreate(payloads, { + transaction: t, + validate: true, + returning: true, + }); + await t.commit(); + const responseBody = { + message: `Created ${created.length} ${modelKey}(s).`, + count: created.length, + }; + responseBody[createdKey] = created; + return res.status(201).json(responseBody); + } catch (error) { + try { await t.rollback(); } catch (_) { /* swallow */ } + log.error({ err: error }, `${modelKey}.bulkCreate failed`); + return res.status(500).json({ message: "Error!", error: String(error) }); + } + }; +} + +module.exports = { makeBulkCreate }; diff --git a/app/controllers/billingtypecontroller.js b/app/controllers/billingtypecontroller.js index 9b04d04..849799f 100644 --- a/app/controllers/billingtypecontroller.js +++ b/app/controllers/billingtypecontroller.js @@ -6,6 +6,7 @@ const db = require('../config/db.config.js'); const log = require('../config/logger.js'); const auth = require('../middleware/auth.js'); const { buildLinkHeader } = require('../middleware/pagination.js'); +const { makeBulkCreate } = require('./_bulk-helpers.js'); const BillingType = db.BillingType; const IsMaster = auth.isMaster; @@ -225,4 +226,14 @@ exports.remove = async (req, res) => { } }; +exports.bulkCreate = makeBulkCreate({ + Model: BillingType, + modelKey: 'BillingType', + compIdField: 'btCompId', + allowedFields: ALLOWED_FIELDS_CREATE, + archField: 'btArch', + bodyKey: 'billingTypes', + createdKey: 'billingTypes', +}); + exports._internals = { IsMaster, GetCompanyId }; diff --git a/app/controllers/inventoryitemcontroller.js b/app/controllers/inventoryitemcontroller.js index 11a3dc9..a7fb83e 100644 --- a/app/controllers/inventoryitemcontroller.js +++ b/app/controllers/inventoryitemcontroller.js @@ -6,6 +6,7 @@ const db = require('../config/db.config.js'); const log = require('../config/logger.js'); const auth = require('../middleware/auth.js'); const { buildLinkHeader } = require('../middleware/pagination.js'); +const { makeBulkCreate } = require('./_bulk-helpers.js'); const InventoryItem = db.InventoryItem; const IsMaster = auth.isMaster; @@ -225,4 +226,14 @@ exports.remove = async (req, res) => { } }; +exports.bulkCreate = makeBulkCreate({ + Model: InventoryItem, + modelKey: 'InventoryItem', + compIdField: 'invitCompId', + allowedFields: ALLOWED_FIELDS_CREATE, + archField: 'invitArch', + bodyKey: 'inventoryItems', + createdKey: 'inventoryItems', +}); + exports._internals = { IsMaster, GetCompanyId }; diff --git a/app/controllers/inventorytransactioncontroller.js b/app/controllers/inventorytransactioncontroller.js index 75ac6f0..cb48ec8 100644 --- a/app/controllers/inventorytransactioncontroller.js +++ b/app/controllers/inventorytransactioncontroller.js @@ -14,6 +14,7 @@ const db = require('../config/db.config.js'); const log = require('../config/logger.js'); const auth = require('../middleware/auth.js'); const { buildLinkHeader } = require('../middleware/pagination.js'); +const { makeBulkCreate } = require('./_bulk-helpers.js'); const InventoryTransaction = db.InventoryTransaction; const IsMaster = auth.isMaster; @@ -221,4 +222,14 @@ exports.remove = async (req, res) => { } }; +exports.bulkCreate = makeBulkCreate({ + Model: InventoryTransaction, + modelKey: 'InventoryTransaction', + compIdField: 'invtCompanyId', + allowedFields: ALLOWED_FIELDS_CREATE, + archField: 'invtArch', + bodyKey: 'inventoryTransactions', + createdKey: 'inventoryTransactions', +}); + exports._internals = { IsMaster, GetCompanyId }; diff --git a/app/controllers/purchaseordervendorcontroller.js b/app/controllers/purchaseordervendorcontroller.js index 986c4bc..d373781 100644 --- a/app/controllers/purchaseordervendorcontroller.js +++ b/app/controllers/purchaseordervendorcontroller.js @@ -11,6 +11,7 @@ const db = require('../config/db.config.js'); const log = require('../config/logger.js'); const auth = require('../middleware/auth.js'); const { buildLinkHeader } = require('../middleware/pagination.js'); +const { makeBulkCreate } = require('./_bulk-helpers.js'); const PurchaseOrderVendor = db.PurchaseOrderVendor; const IsMaster = auth.isMaster; @@ -236,4 +237,14 @@ exports.remove = async (req, res) => { } }; +exports.bulkCreate = makeBulkCreate({ + Model: PurchaseOrderVendor, + modelKey: 'PurchaseOrderVendor', + compIdField: 'povCompId', + allowedFields: ALLOWED_FIELDS_CREATE, + archField: 'povArch', + bodyKey: 'vendors', + createdKey: 'vendors', +}); + exports._internals = { IsMaster, GetCompanyId }; diff --git a/app/controllers/workercontroller.js b/app/controllers/workercontroller.js index 2a07194..2a67b5b 100644 --- a/app/controllers/workercontroller.js +++ b/app/controllers/workercontroller.js @@ -6,6 +6,7 @@ const db = require('../config/db.config.js'); const log = require('../config/logger.js'); const auth = require('../middleware/auth.js'); const { buildLinkHeader } = require('../middleware/pagination.js'); +const { makeBulkCreate } = require('./_bulk-helpers.js'); const Worker = db.Worker; const IsMaster = auth.isMaster; @@ -261,4 +262,14 @@ exports.remove = async (req, res) => { } }; +exports.bulkCreate = makeBulkCreate({ + Model: Worker, + modelKey: 'Worker', + compIdField: 'workerCompId', + allowedFields: ALLOWED_FIELDS_CREATE, + archField: 'workerArch', + bodyKey: 'workers', + createdKey: 'workers', +}); + exports._internals = { IsMaster, GetCompanyId }; diff --git a/app/routers/router.js b/app/routers/router.js index 62b5f9f..f1428f8 100644 --- a/app/routers/router.js +++ b/app/routers/router.js @@ -156,6 +156,15 @@ router.delete( ); // v1 worker routes. +// +// /bulk goes BEFORE /:id-bearing routes so Express's matcher doesn't +// route the literal "bulk" segment through the :id-typed validator. +// (Same trick as /v1/customer/bulk and /v1/customer/export.csv.) +router.post( + '/v1/worker/bulk', + v.body(workerSchemas.bulkWorkerBody), + worker.bulkCreate, +); router.post( '/v1/worker', v.body(workerSchemas.createWorkerBody), @@ -185,6 +194,11 @@ router.delete( ); // v1 billingtype routes. +router.post( + '/v1/billingtype/bulk', + v.body(billingTypeSchemas.bulkBillingTypeBody), + billingType.bulkCreate, +); router.post( '/v1/billingtype', v.body(billingTypeSchemas.createBillingTypeBody), @@ -214,6 +228,11 @@ router.delete( ); // v1 inventoryitem routes. +router.post( + '/v1/inventoryitem/bulk', + v.body(inventoryItemSchemas.bulkInventoryItemBody), + inventoryItem.bulkCreate, +); router.post( '/v1/inventoryitem', v.body(inventoryItemSchemas.createInventoryItemBody), @@ -445,6 +464,11 @@ router.delete( ); // v1 purchaseordervendor routes. Direct compId scoping via povCompId. +router.post( + '/v1/purchaseordervendor/bulk', + v.body(purchaseOrderVendorSchemas.bulkBody), + purchaseOrderVendor.bulkCreate, +); router.post( '/v1/purchaseordervendor', v.body(purchaseOrderVendorSchemas.createBody), @@ -532,6 +556,11 @@ router.delete( ); // v1 inventorytransaction routes. Direct compId scoping via invtCompanyId. +router.post( + '/v1/inventorytransaction/bulk', + v.body(inventoryTransactionSchemas.bulkBody), + inventoryTransaction.bulkCreate, +); router.post( '/v1/inventorytransaction', v.body(inventoryTransactionSchemas.createBody), diff --git a/app/schemas/billingtype.schema.js b/app/schemas/billingtype.schema.js index 67430be..98724a1 100644 --- a/app/schemas/billingtype.schema.js +++ b/app/schemas/billingtype.schema.js @@ -30,9 +30,16 @@ const listByCompanyQuery = z.object({ message: 'Unexpected query parameter. Allowed: limit, offset.', }); +const bulkBillingTypeBody = z.object({ + billingTypes: z.array(createBillingTypeBody).min(1).max(500), +}).strict({ + message: 'Unexpected field in body. Whitelist: billingTypes (array).', +}); + module.exports = { intIdParam, createBillingTypeBody, updateBillingTypeBody, listByCompanyQuery, + bulkBillingTypeBody, }; diff --git a/app/schemas/inventoryitem.schema.js b/app/schemas/inventoryitem.schema.js index 9929b3f..db83f13 100644 --- a/app/schemas/inventoryitem.schema.js +++ b/app/schemas/inventoryitem.schema.js @@ -30,9 +30,16 @@ const listByCompanyQuery = z.object({ message: 'Unexpected query parameter. Allowed: limit, offset.', }); +const bulkInventoryItemBody = z.object({ + inventoryItems: z.array(createInventoryItemBody).min(1).max(500), +}).strict({ + message: 'Unexpected field in body. Whitelist: inventoryItems (array).', +}); + module.exports = { intIdParam, createInventoryItemBody, updateInventoryItemBody, listByCompanyQuery, + bulkInventoryItemBody, }; diff --git a/app/schemas/inventorytransaction.schema.js b/app/schemas/inventorytransaction.schema.js index d7dbb0c..ad142c9 100644 --- a/app/schemas/inventorytransaction.schema.js +++ b/app/schemas/inventorytransaction.schema.js @@ -34,9 +34,16 @@ const listByCompanyQuery = z.object({ message: 'Unexpected query parameter. Allowed: limit, offset.', }); +const bulkBody = z.object({ + inventoryTransactions: z.array(createBody).min(1).max(500), +}).strict({ + message: 'Unexpected field in body. Whitelist: inventoryTransactions (array).', +}); + module.exports = { intIdParam, createBody, updateBody, listByCompanyQuery, + bulkBody, }; diff --git a/app/schemas/purchaseordervendor.schema.js b/app/schemas/purchaseordervendor.schema.js index 84dae40..c745802 100644 --- a/app/schemas/purchaseordervendor.schema.js +++ b/app/schemas/purchaseordervendor.schema.js @@ -51,9 +51,16 @@ const listByCompanyQuery = z.object({ message: 'Unexpected query parameter. Allowed: limit, offset.', }); +const bulkBody = z.object({ + vendors: z.array(createBody).min(1).max(500), +}).strict({ + message: 'Unexpected field in body. Whitelist: vendors (array).', +}); + module.exports = { intIdParam, createBody, updateBody, listByCompanyQuery, + bulkBody, }; diff --git a/app/schemas/worker.schema.js b/app/schemas/worker.schema.js index 9e0ce0c..f45f31e 100644 --- a/app/schemas/worker.schema.js +++ b/app/schemas/worker.schema.js @@ -48,9 +48,21 @@ const listByCompanyQuery = z.object({ message: 'Unexpected query parameter. Allowed: limit, offset.', }); +/** + * POST /v1/worker/bulk body. Mirrors customer's bulk shape. Each entry + * is validated by the same createWorkerBody so the whitelist semantics + * stay uniform between single and bulk paths. + */ +const bulkWorkerBody = z.object({ + workers: z.array(createWorkerBody).min(1).max(500), +}).strict({ + message: 'Unexpected field in body. Whitelist: workers (array).', +}); + module.exports = { intIdParam, createWorkerBody, updateWorkerBody, listByCompanyQuery, + bulkWorkerBody, }; diff --git a/tests/api/bulk-direct-compid.test.js b/tests/api/bulk-direct-compid.test.js new file mode 100644 index 0000000..15c3e92 --- /dev/null +++ b/tests/api/bulk-direct-compid.test.js @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark +// +// HTTP smoke tests for the 5 bulk endpoints added in P3-H: +// POST /v1/worker/bulk +// POST /v1/billingtype/bulk +// POST /v1/inventoryitem/bulk +// POST /v1/inventorytransaction/bulk +// POST /v1/purchaseordervendor/bulk +// +// All five share the direct-compId scoping pattern via +// app/controllers/_bulk-helpers.js. We exercise: +// - the route is mounted (not 404) +// - the auth contract (403 without authKey) +// - the schema's outer-key validation (400 when missing / empty / +// past the 500 cap) +// +// Transactional roll-back and master-vs-non-master scoping live in +// integration tests (gated on a real DB). + +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([]), + transaction: vi.fn().mockResolvedValue({ + commit: vi.fn().mockResolvedValue(undefined), + rollback: vi.fn().mockResolvedValue(undefined), + }), + QueryTypes: { SELECT: 'SELECT' }, + }, + Sequelize: { Op: {} }, + Customer: {}, + Worker: { bulkCreate: vi.fn().mockResolvedValue([]) }, + BillingType: { bulkCreate: vi.fn().mockResolvedValue([]) }, + InventoryItem: { bulkCreate: vi.fn().mockResolvedValue([]) }, + InventoryTransaction:{ bulkCreate: vi.fn().mockResolvedValue([]) }, + PurchaseOrderVendor: { bulkCreate: vi.fn().mockResolvedValue([]) }, + Company: {}, Job: {}, Invoice: {}, CustomerPayment: {}, + InvoiceJob: {}, ProductEntry: {}, VersionInfo: {}, + PurchaseOrderHeader: {}, PurchaseOrderLine: {}, TimeEntry: {}, + 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); +}); + +// Each entry: [path, bodyKey, sampleEntry] — the sampleEntry must +// satisfy zod for the create schema (otherwise we can't tell a 400 +// from an empty-array case from a 400 from a bad entry). +const ENTITIES = [ + [ + '/v1/worker/bulk', + 'workers', + { + workerFName: 'Ada', workerLName: 'Lovelace', + workerTitle: 'Engineer', workerDefaultBillType: 1, + }, + ], + [ + '/v1/billingtype/bulk', + 'billingTypes', + { btName: 'Standard', btHourlyRate: 100 }, + ], + [ + '/v1/inventoryitem/bulk', + 'inventoryItems', + { invitDescription: 'Widget', invitQty: 5 }, + ], + [ + '/v1/inventorytransaction/bulk', + 'inventoryTransactions', + { invtDirection: 1, invtInitId: 1 }, + ], + [ + '/v1/purchaseordervendor/bulk', + 'vendors', + { povName: 'Vendor A', povMailingAddress1: '123 Main', povMailingCity: 'Lincoln' }, + ], +]; + +describe('bulk endpoints: auth contract', () => { + test.each(ENTITIES)('POST %s returns 403 when authKey header is missing', async (path, bodyKey, sample) => { + const body = {}; + body[bodyKey] = [sample]; + const res = await request(app).post(path).send(body); + expect(res.status).toBe(403); + }); +}); + +describe('bulk endpoints: body validation', () => { + test.each(ENTITIES)('POST %s 400 when outer field is missing', async (path) => { + const res = await request(app).post(path).set('authKey', 'any').send({}); + expect(res.status).toBe(400); + }); + + test.each(ENTITIES)('POST %s 400 when array is empty', async (path, bodyKey) => { + const body = {}; + body[bodyKey] = []; + const res = await request(app).post(path).set('authKey', 'any').send(body); + expect(res.status).toBe(400); + }); + + test.each(ENTITIES)('POST %s 400 when batch exceeds 500-entry cap', async (path, bodyKey, sample) => { + const body = {}; + body[bodyKey] = new Array(501).fill(sample); + const res = await request(app).post(path).set('authKey', 'any').send(body); + expect(res.status).toBe(400); + }); + + test.each(ENTITIES)('POST %s 400 when a top-level unknown field is present', async (path, bodyKey, sample) => { + const body = {}; + body[bodyKey] = [sample]; + body.bogus = 'no'; + const res = await request(app).post(path).set('authKey', 'any').send(body); + expect(res.status).toBe(400); + }); +}); + +describe('bulk endpoints: route mounting (not 404)', () => { + test.each(ENTITIES)('POST %s reaches the validator/controller layer (not a missing route)', async (path, bodyKey, sample) => { + const body = {}; + body[bodyKey] = [sample]; + const res = await request(app).post(path).set('authKey', 'any').send(body); + expect(res.status).not.toBe(404); + }); +});