Skip to content

Commit e68b5bd

Browse files
CryptoJonesAaron K. Clarkclaude
authored
feat(api): bulk-create endpoints for 5 direct-compId entities (P3-H) (#81)
Architect audit P3-H. New `POST /v1/<entity>/bulk` on: - Worker (workerCompId) - BillingType (btCompId) - InventoryItem (invitCompId) - InventoryTransaction (invtCompanyId) - PurchaseOrderVendor (povCompId) Mirrors the shape of `POST /v1/customer/bulk` that's been in the API since the initial port: 500-entry cap, zod-strict whitelist, all-or- nothing transactional insert, master vs. non-master scoping enforced per entry (master keys MUST specify the compId column; non-master keys can omit it — it defaults to the authKey's owning company — or must match it if specified). Shared factory: `app/controllers/_bulk-helpers.js#makeBulkCreate` parameterizes over (Model, modelKey, compIdField, allowedFields, archField, bodyKey, createdKey). The 5 controllers gain ~10 lines each instead of ~80; the factory itself is ~95 LOC. Net delta: roughly +150 lines, vs. ~+400 lines if we'd hand-rolled all five. Customer's pre-existing `bulkCreate` is left alone for this PR — unifying it with the factory is a mechanical follow-up and a separate risk surface. Route order: each `/bulk` route is mounted BEFORE its `/:id` siblings 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`. Tests - `tests/api/bulk-direct-compid.test.js` (30 cases): 5 entities x 6 assertions covering auth contract, missing-outer-field 400, empty-array 400, 501-entry-cap 400, unknown-top-level-field 400, and route mounting (not 404). - Full suite: 357 pass / 4 skip (was 327/4). Co-authored-by: Aaron K. Clark <akclark@thenetwerk.net> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e055c88 commit e68b5bd

14 files changed

Lines changed: 399 additions & 0 deletions

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## [Unreleased]
99

1010
### Added
11+
- **Bulk-create endpoints for 5 direct-compId entities** (P3-H).
12+
New `POST /v1/<entity>/bulk` on Worker, BillingType, InventoryItem,
13+
InventoryTransaction, and PurchaseOrderVendor. Same shape as the
14+
existing `POST /v1/customer/bulk`: 500-entry cap, zod-strict
15+
whitelist, transactional all-or-nothing insert, master vs.
16+
non-master scoping enforced per entry. Shared
17+
`app/controllers/_bulk-helpers.js#makeBulkCreate` factory removes
18+
~150 lines of would-be duplication; Customer's pre-existing handler
19+
keeps its bespoke logic until a follow-up unifies them.
1120
- **Idempotency-Key support on POST routes** (P3-G).
1221
Clients may send an `Idempotency-Key: <printable-ASCII, 1-255>`
1322
header on any POST under `/v1/*`. The first response (status +

app/controllers/_bulk-helpers.js

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// Copyright 2026 Aaron K. Clark
3+
"use strict";
4+
5+
/**
6+
* Shared factory for bulk-create controllers on entities that scope
7+
* directly to a single company via a *CompId column. Customer's
8+
* bulkCreate predates this helper and has the same shape baked in;
9+
* we don't migrate it here to keep the diff focused on the new
10+
* endpoints (P3-H), but a follow-up should consolidate them.
11+
*
12+
* What this factory replaces: 5 near-identical controllers
13+
* (worker/billingtype/inventoryitem/inventorytransaction/
14+
* purchaseordervendor) each repeating the same auth-scope-loop-
15+
* transaction-bulkCreate-handle-error scaffold.
16+
*
17+
* What varies between entities — passed as config:
18+
* - Model the sequelize model (db.Worker etc.)
19+
* - modelKey string label for logs ("Worker", "BillingType")
20+
* - compIdField the company-scope column ("workerCompId", "btCompId",
21+
* "invitCompId", "invtCompanyId", "povCompId")
22+
* - allowedFields whitelist for each entry (the same list the
23+
* single-create endpoint accepts, minus the *Arch
24+
* column which the controller sets to false)
25+
* - archField soft-delete column ("workerArch", "btArch", etc.)
26+
* - bodyKey JSON key the array hangs off ("workers",
27+
* "billingTypes", etc.) — matches the zod schema's
28+
* outer key.
29+
* - createdKey response key for the inserted rows ("workers", ...)
30+
*/
31+
32+
const db = require('../config/db.config.js');
33+
const log = require('../config/logger.js');
34+
const auth = require('../middleware/auth.js');
35+
36+
function makeBulkCreate({
37+
Model,
38+
modelKey,
39+
compIdField,
40+
allowedFields,
41+
archField,
42+
bodyKey,
43+
createdKey,
44+
}) {
45+
return async function bulkCreate(req, res) {
46+
const authKey = req.get('authKey');
47+
if (!authKey) {
48+
return res.status(403).json({ message: "Authorization key not sent." });
49+
}
50+
51+
let isAuthKeyMasterKey;
52+
try {
53+
isAuthKeyMasterKey = await auth.isMaster(authKey);
54+
} catch (error) {
55+
log.error({ err: error }, `${modelKey}: isMaster failed`);
56+
return res.status(500).json({ message: "Error!", error: String(error) });
57+
}
58+
59+
const input = (req.body && Array.isArray(req.body[bodyKey]))
60+
? req.body[bodyKey]
61+
: [];
62+
if (input.length === 0) {
63+
return res.status(400).json({ message: `${bodyKey} array is required and must be non-empty.` });
64+
}
65+
66+
// Resolve authKey's company once for non-master path.
67+
let authKeyCompanyId = null;
68+
if (!isAuthKeyMasterKey) {
69+
try {
70+
authKeyCompanyId = await auth.getCompanyId(authKey);
71+
} catch (error) {
72+
log.error({ err: error }, `${modelKey}: getCompanyId failed`);
73+
return res.status(500).json({ message: "Error!", error: String(error) });
74+
}
75+
if (authKeyCompanyId === -1) {
76+
return res.status(403).json({ message: "Invalid Authorization Key." });
77+
}
78+
}
79+
80+
// Whitelist + auth-scope each entry.
81+
const payloads = [];
82+
for (let i = 0; i < input.length; i += 1) {
83+
const entry = input[i] || {};
84+
const p = {};
85+
for (const f of allowedFields) {
86+
if (entry[f] !== undefined) p[f] = entry[f];
87+
}
88+
if (isAuthKeyMasterKey) {
89+
if (p[compIdField] === undefined || Number(p[compIdField]) <= 0) {
90+
return res.status(400).json({
91+
message: `${bodyKey}[${i}]: master-key requests must specify ${compIdField}.`,
92+
});
93+
}
94+
} else {
95+
if (p[compIdField] !== undefined && Number(p[compIdField]) !== authKeyCompanyId) {
96+
return res.status(403).json({
97+
message: `${bodyKey}[${i}]: cannot create for a company you do not belong to.`,
98+
});
99+
}
100+
p[compIdField] = authKeyCompanyId;
101+
}
102+
// archField intentionally defaulted to false here so
103+
// partially-archived bulk inserts can't be smuggled in.
104+
p[archField] = false;
105+
payloads.push(p);
106+
}
107+
108+
const t = await db.sequelize.transaction();
109+
try {
110+
const created = await Model.bulkCreate(payloads, {
111+
transaction: t,
112+
validate: true,
113+
returning: true,
114+
});
115+
await t.commit();
116+
const responseBody = {
117+
message: `Created ${created.length} ${modelKey}(s).`,
118+
count: created.length,
119+
};
120+
responseBody[createdKey] = created;
121+
return res.status(201).json(responseBody);
122+
} catch (error) {
123+
try { await t.rollback(); } catch (_) { /* swallow */ }
124+
log.error({ err: error }, `${modelKey}.bulkCreate failed`);
125+
return res.status(500).json({ message: "Error!", error: String(error) });
126+
}
127+
};
128+
}
129+
130+
module.exports = { makeBulkCreate };

app/controllers/billingtypecontroller.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const db = require('../config/db.config.js');
66
const log = require('../config/logger.js');
77
const auth = require('../middleware/auth.js');
88
const { buildLinkHeader } = require('../middleware/pagination.js');
9+
const { makeBulkCreate } = require('./_bulk-helpers.js');
910
const BillingType = db.BillingType;
1011

1112
const IsMaster = auth.isMaster;
@@ -225,4 +226,14 @@ exports.remove = async (req, res) => {
225226
}
226227
};
227228

229+
exports.bulkCreate = makeBulkCreate({
230+
Model: BillingType,
231+
modelKey: 'BillingType',
232+
compIdField: 'btCompId',
233+
allowedFields: ALLOWED_FIELDS_CREATE,
234+
archField: 'btArch',
235+
bodyKey: 'billingTypes',
236+
createdKey: 'billingTypes',
237+
});
238+
228239
exports._internals = { IsMaster, GetCompanyId };

app/controllers/inventoryitemcontroller.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const db = require('../config/db.config.js');
66
const log = require('../config/logger.js');
77
const auth = require('../middleware/auth.js');
88
const { buildLinkHeader } = require('../middleware/pagination.js');
9+
const { makeBulkCreate } = require('./_bulk-helpers.js');
910
const InventoryItem = db.InventoryItem;
1011

1112
const IsMaster = auth.isMaster;
@@ -225,4 +226,14 @@ exports.remove = async (req, res) => {
225226
}
226227
};
227228

229+
exports.bulkCreate = makeBulkCreate({
230+
Model: InventoryItem,
231+
modelKey: 'InventoryItem',
232+
compIdField: 'invitCompId',
233+
allowedFields: ALLOWED_FIELDS_CREATE,
234+
archField: 'invitArch',
235+
bodyKey: 'inventoryItems',
236+
createdKey: 'inventoryItems',
237+
});
238+
228239
exports._internals = { IsMaster, GetCompanyId };

app/controllers/inventorytransactioncontroller.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const db = require('../config/db.config.js');
1414
const log = require('../config/logger.js');
1515
const auth = require('../middleware/auth.js');
1616
const { buildLinkHeader } = require('../middleware/pagination.js');
17+
const { makeBulkCreate } = require('./_bulk-helpers.js');
1718
const InventoryTransaction = db.InventoryTransaction;
1819

1920
const IsMaster = auth.isMaster;
@@ -221,4 +222,14 @@ exports.remove = async (req, res) => {
221222
}
222223
};
223224

225+
exports.bulkCreate = makeBulkCreate({
226+
Model: InventoryTransaction,
227+
modelKey: 'InventoryTransaction',
228+
compIdField: 'invtCompanyId',
229+
allowedFields: ALLOWED_FIELDS_CREATE,
230+
archField: 'invtArch',
231+
bodyKey: 'inventoryTransactions',
232+
createdKey: 'inventoryTransactions',
233+
});
234+
224235
exports._internals = { IsMaster, GetCompanyId };

app/controllers/purchaseordervendorcontroller.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const db = require('../config/db.config.js');
1111
const log = require('../config/logger.js');
1212
const auth = require('../middleware/auth.js');
1313
const { buildLinkHeader } = require('../middleware/pagination.js');
14+
const { makeBulkCreate } = require('./_bulk-helpers.js');
1415
const PurchaseOrderVendor = db.PurchaseOrderVendor;
1516

1617
const IsMaster = auth.isMaster;
@@ -236,4 +237,14 @@ exports.remove = async (req, res) => {
236237
}
237238
};
238239

240+
exports.bulkCreate = makeBulkCreate({
241+
Model: PurchaseOrderVendor,
242+
modelKey: 'PurchaseOrderVendor',
243+
compIdField: 'povCompId',
244+
allowedFields: ALLOWED_FIELDS_CREATE,
245+
archField: 'povArch',
246+
bodyKey: 'vendors',
247+
createdKey: 'vendors',
248+
});
249+
239250
exports._internals = { IsMaster, GetCompanyId };

app/controllers/workercontroller.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const db = require('../config/db.config.js');
66
const log = require('../config/logger.js');
77
const auth = require('../middleware/auth.js');
88
const { buildLinkHeader } = require('../middleware/pagination.js');
9+
const { makeBulkCreate } = require('./_bulk-helpers.js');
910
const Worker = db.Worker;
1011

1112
const IsMaster = auth.isMaster;
@@ -261,4 +262,14 @@ exports.remove = async (req, res) => {
261262
}
262263
};
263264

265+
exports.bulkCreate = makeBulkCreate({
266+
Model: Worker,
267+
modelKey: 'Worker',
268+
compIdField: 'workerCompId',
269+
allowedFields: ALLOWED_FIELDS_CREATE,
270+
archField: 'workerArch',
271+
bodyKey: 'workers',
272+
createdKey: 'workers',
273+
});
274+
264275
exports._internals = { IsMaster, GetCompanyId };

app/routers/router.js

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,15 @@ router.delete(
156156
);
157157

158158
// v1 worker routes.
159+
//
160+
// /bulk goes BEFORE /:id-bearing routes so Express's matcher doesn't
161+
// route the literal "bulk" segment through the :id-typed validator.
162+
// (Same trick as /v1/customer/bulk and /v1/customer/export.csv.)
163+
router.post(
164+
'/v1/worker/bulk',
165+
v.body(workerSchemas.bulkWorkerBody),
166+
worker.bulkCreate,
167+
);
159168
router.post(
160169
'/v1/worker',
161170
v.body(workerSchemas.createWorkerBody),
@@ -185,6 +194,11 @@ router.delete(
185194
);
186195

187196
// v1 billingtype routes.
197+
router.post(
198+
'/v1/billingtype/bulk',
199+
v.body(billingTypeSchemas.bulkBillingTypeBody),
200+
billingType.bulkCreate,
201+
);
188202
router.post(
189203
'/v1/billingtype',
190204
v.body(billingTypeSchemas.createBillingTypeBody),
@@ -214,6 +228,11 @@ router.delete(
214228
);
215229

216230
// v1 inventoryitem routes.
231+
router.post(
232+
'/v1/inventoryitem/bulk',
233+
v.body(inventoryItemSchemas.bulkInventoryItemBody),
234+
inventoryItem.bulkCreate,
235+
);
217236
router.post(
218237
'/v1/inventoryitem',
219238
v.body(inventoryItemSchemas.createInventoryItemBody),
@@ -445,6 +464,11 @@ router.delete(
445464
);
446465

447466
// v1 purchaseordervendor routes. Direct compId scoping via povCompId.
467+
router.post(
468+
'/v1/purchaseordervendor/bulk',
469+
v.body(purchaseOrderVendorSchemas.bulkBody),
470+
purchaseOrderVendor.bulkCreate,
471+
);
448472
router.post(
449473
'/v1/purchaseordervendor',
450474
v.body(purchaseOrderVendorSchemas.createBody),
@@ -532,6 +556,11 @@ router.delete(
532556
);
533557

534558
// v1 inventorytransaction routes. Direct compId scoping via invtCompanyId.
559+
router.post(
560+
'/v1/inventorytransaction/bulk',
561+
v.body(inventoryTransactionSchemas.bulkBody),
562+
inventoryTransaction.bulkCreate,
563+
);
535564
router.post(
536565
'/v1/inventorytransaction',
537566
v.body(inventoryTransactionSchemas.createBody),

app/schemas/billingtype.schema.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,16 @@ const listByCompanyQuery = z.object({
3030
message: 'Unexpected query parameter. Allowed: limit, offset.',
3131
});
3232

33+
const bulkBillingTypeBody = z.object({
34+
billingTypes: z.array(createBillingTypeBody).min(1).max(500),
35+
}).strict({
36+
message: 'Unexpected field in body. Whitelist: billingTypes (array).',
37+
});
38+
3339
module.exports = {
3440
intIdParam,
3541
createBillingTypeBody,
3642
updateBillingTypeBody,
3743
listByCompanyQuery,
44+
bulkBillingTypeBody,
3845
};

app/schemas/inventoryitem.schema.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,16 @@ const listByCompanyQuery = z.object({
3030
message: 'Unexpected query parameter. Allowed: limit, offset.',
3131
});
3232

33+
const bulkInventoryItemBody = z.object({
34+
inventoryItems: z.array(createInventoryItemBody).min(1).max(500),
35+
}).strict({
36+
message: 'Unexpected field in body. Whitelist: inventoryItems (array).',
37+
});
38+
3339
module.exports = {
3440
intIdParam,
3541
createInventoryItemBody,
3642
updateInventoryItemBody,
3743
listByCompanyQuery,
44+
bulkInventoryItemBody,
3845
};

0 commit comments

Comments
 (0)