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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<entity>/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: <printable-ASCII, 1-255>`
header on any POST under `/v1/*`. The first response (status +
Expand Down
130 changes: 130 additions & 0 deletions app/controllers/_bulk-helpers.js
Original file line number Diff line number Diff line change
@@ -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 };
11 changes: 11 additions & 0 deletions app/controllers/billingtypecontroller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 };
11 changes: 11 additions & 0 deletions app/controllers/inventoryitemcontroller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 };
11 changes: 11 additions & 0 deletions app/controllers/inventorytransactioncontroller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 };
11 changes: 11 additions & 0 deletions app/controllers/purchaseordervendorcontroller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 };
11 changes: 11 additions & 0 deletions app/controllers/workercontroller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 };
29 changes: 29 additions & 0 deletions app/routers/router.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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),
Expand Down
7 changes: 7 additions & 0 deletions app/schemas/billingtype.schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
7 changes: 7 additions & 0 deletions app/schemas/inventoryitem.schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
7 changes: 7 additions & 0 deletions app/schemas/inventorytransaction.schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
7 changes: 7 additions & 0 deletions app/schemas/purchaseordervendor.schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Loading
Loading