Skip to content

Commit cd17c2f

Browse files
CryptoJonesAaron K. Clarkclaude
authored
feat(api): bulk-create endpoints for 7 indirect-scoped entities (P3-H2) (#88)
Follow-up to P3-H. The bulk surface was previously limited to the 5 entities with a direct *CompId column. This PR extends it to the remaining 7 soft-deletable entities, whose auth scope is resolved *through* a parent FK rather than carried directly: Customer-scoped (parent → Customer.custCompId): - POST /v1/job/bulk jobCustId - POST /v1/invoice/bulk invCustId - POST /v1/customerpayment/bulk cpayCustId Job-scoped (parent → Job → Customer.custCompId): - POST /v1/invoicejob/bulk injbJobId - POST /v1/productentry/bulk pentJobId Vendor/header-scoped: - POST /v1/purchaseorderheader/bulk pohPovId → vendor.povCompId - POST /v1/purchaseorderline/bulk polpoh → header → vendor Mechanics: - New factory `makeBulkCreateIndirect` in `app/controllers/_bulk-helpers.js` parameterizes over `parentFkField` + `resolveParentCompanyId(parentId)`. The 7 controllers gain ~10 LOC each instead of ~120. - Per-entry validation: parent FK is REQUIRED on every entry. For non-master keys the resolved parent company must equal the caller's company (else 403 with the offending index); master keys aren't pinned to a company so any resolved parent is fine. - Same 500-entry cap and transactional all-or-nothing insert as the direct family. Together with P3-H, the bulk surface now covers all 13 soft- deletable entities. Tests - `tests/api/bulk-indirect-scope.test.js`: 49 cases (7 entities x 7 assertions). Auth contract, outer-field 400s, empty/501-cap 400s, unknown-field 400, missing-parent-FK 400, route mounting. - Full suite: 469 pass / 4 skip (was 420/4 — +49 net new tests). - Lint clean. Co-authored-by: Aaron K. Clark <akclark@thenetwerk.net> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent cf3ddf3 commit cd17c2f

18 files changed

Lines changed: 497 additions & 1 deletion

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
- **Bulk-create endpoints for 7 indirect-scoped entities** (P3-H2).
12+
New `POST /v1/<entity>/bulk` on Job, Invoice, CustomerPayment,
13+
InvoiceJob, ProductEntry, PurchaseOrderHeader, PurchaseOrderLine.
14+
Same 500-entry cap and transactional all-or-nothing semantics as
15+
the direct-compId family from P3-H, but per-entry auth scope is
16+
resolved through the parent FK (Customer / Job / Vendor / Header)
17+
via the existing helpers in `app/middleware/auth.js`. A new
18+
`makeBulkCreateIndirect` factory in
19+
`app/controllers/_bulk-helpers.js` parameterizes over the parent
20+
FK column + the auth-helper that resolves it; the 7 controllers
21+
gain ~10 LOC each instead of ~120. The bulk surface now covers
22+
**all 13 soft-deletable entities.**
23+
1024
### Changed
1125
- **`app/middleware/auth.js` is now testable end-to-end** (P5-M).
1226
Two changes:

app/controllers/_bulk-helpers.js

Lines changed: 142 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,4 +127,145 @@ function makeBulkCreate({
127127
};
128128
}
129129

130-
module.exports = { makeBulkCreate };
130+
/**
131+
* Sibling factory for entities that scope auth INDIRECTLY through
132+
* a parent FK rather than carrying their own *CompId column.
133+
*
134+
* Examples:
135+
* - Job (jobCustId → Customer.custCompId)
136+
* - Invoice (invCustId → Customer.custCompId)
137+
* - CustomerPayment (cpayCustId → Customer.custCompId)
138+
* - InvoiceJob (injbJobId → Job → Customer.custCompId)
139+
* - ProductEntry (pentJobId → Job → Customer.custCompId)
140+
* - PurchaseOrderHeader (pohPovId → PurchaseOrderVendor.povCompId)
141+
* - PurchaseOrderLine (polpoh → PurchaseOrderHeader → vendor)
142+
*
143+
* Config (additions vs makeBulkCreate):
144+
* - parentFkField the column on each entry that names the
145+
* parent row whose scope governs (e.g.
146+
* 'jobCustId', 'pohPovId', 'polpoh')
147+
* - resolveParentCompanyId(parentId)
148+
* async function returning the int company
149+
* id, or -1 if the parent is missing/archived
150+
* /unresolved (e.g. auth.getCompanyIdByCustomerId).
151+
* - compIdField NOT used here — kept off the signature so
152+
* callers don't confuse this with the direct
153+
* version. If the entity grows its own column
154+
* later, switch to makeBulkCreate.
155+
*
156+
* Per-entry contract:
157+
* - parentFkField is REQUIRED on every entry. We need it to
158+
* resolve scope; absent it we can't authorize the entry
159+
* (return 400 with the offending index).
160+
* - For non-master keys: the resolved parent company must equal
161+
* authKey's company, else 403 with the offending index. Catches
162+
* cross-tenant smuggling attempts in a single batch.
163+
* - For master keys: the parent must resolve (404-style 400 if
164+
* not), but master keys aren't pinned to a company so any
165+
* resolved company is fine.
166+
*/
167+
function makeBulkCreateIndirect({
168+
Model,
169+
modelKey,
170+
parentFkField,
171+
resolveParentCompanyId,
172+
allowedFields,
173+
archField,
174+
bodyKey,
175+
createdKey,
176+
}) {
177+
return async function bulkCreate(req, res) {
178+
const authKey = req.get('authKey');
179+
if (!authKey) {
180+
return res.status(403).json({ message: "Authorization key not sent." });
181+
}
182+
183+
let isAuthKeyMasterKey;
184+
try {
185+
isAuthKeyMasterKey = await auth.isMaster(authKey);
186+
} catch (error) {
187+
log.error({ err: error }, `${modelKey}: isMaster failed`);
188+
return res.status(500).json({ message: "Error!", error: String(error) });
189+
}
190+
191+
const input = (req.body && Array.isArray(req.body[bodyKey]))
192+
? req.body[bodyKey]
193+
: [];
194+
if (input.length === 0) {
195+
return res.status(400).json({ message: `${bodyKey} array is required and must be non-empty.` });
196+
}
197+
198+
let authKeyCompanyId = null;
199+
if (!isAuthKeyMasterKey) {
200+
try {
201+
authKeyCompanyId = await auth.getCompanyId(authKey);
202+
} catch (error) {
203+
log.error({ err: error }, `${modelKey}: getCompanyId failed`);
204+
return res.status(500).json({ message: "Error!", error: String(error) });
205+
}
206+
if (authKeyCompanyId === -1) {
207+
return res.status(403).json({ message: "Invalid Authorization Key." });
208+
}
209+
}
210+
211+
// Whitelist + per-entry parent-FK scope check.
212+
const payloads = [];
213+
for (let i = 0; i < input.length; i += 1) {
214+
const entry = input[i] || {};
215+
const p = {};
216+
for (const f of allowedFields) {
217+
if (entry[f] !== undefined) p[f] = entry[f];
218+
}
219+
const parentId = Number(p[parentFkField]);
220+
if (!Number.isInteger(parentId) || parentId <= 0) {
221+
return res.status(400).json({
222+
message: `${bodyKey}[${i}]: ${parentFkField} is required.`,
223+
});
224+
}
225+
226+
let parentCompId;
227+
try {
228+
parentCompId = await resolveParentCompanyId(parentId);
229+
} catch (error) {
230+
log.error({ err: error }, `${modelKey}: parent scope resolve failed`);
231+
return res.status(500).json({ message: "Error!", error: String(error) });
232+
}
233+
if (parentCompId === -1) {
234+
return res.status(400).json({
235+
message: `${bodyKey}[${i}]: parent row not found or archived.`,
236+
});
237+
}
238+
239+
if (!isAuthKeyMasterKey && parentCompId !== authKeyCompanyId) {
240+
return res.status(403).json({
241+
message: `${bodyKey}[${i}]: cannot create for a company you do not belong to.`,
242+
});
243+
}
244+
245+
p[archField] = false;
246+
payloads.push(p);
247+
}
248+
249+
const t = await db.sequelize.transaction();
250+
try {
251+
const created = await Model.bulkCreate(payloads, {
252+
transaction: t,
253+
validate: true,
254+
returning: true,
255+
});
256+
await t.commit();
257+
const responseBody = {
258+
message: `Created ${created.length} ${modelKey}(s).`,
259+
count: created.length,
260+
};
261+
responseBody[createdKey] = created;
262+
return res.status(201).json(responseBody);
263+
} catch (error) {
264+
try { await t.rollback(); } catch (_) { /* swallow */ }
265+
log.error({ err: error }, `${modelKey}.bulkCreate failed`);
266+
return res.status(500).json({ message: "Error!", error: String(error) });
267+
}
268+
};
269+
}
270+
271+
module.exports = { makeBulkCreate, makeBulkCreateIndirect };

app/controllers/customerpaymentcontroller.js

Lines changed: 12 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 { makeBulkCreateIndirect } = require('./_bulk-helpers.js');
910
const CustomerPayment = db.CustomerPayment;
1011

1112
const IsMaster = auth.isMaster;
@@ -210,4 +211,15 @@ exports.remove = async (req, res) => {
210211
}
211212
};
212213

214+
exports.bulkCreate = makeBulkCreateIndirect({
215+
Model: CustomerPayment,
216+
modelKey: 'CustomerPayment',
217+
parentFkField: 'cpayCustId',
218+
resolveParentCompanyId: auth.getCompanyIdByCustomerId,
219+
allowedFields: ALLOWED_FIELDS_CREATE,
220+
archField: 'cpayArch',
221+
bodyKey: 'customerPayments',
222+
createdKey: 'customerPayments',
223+
});
224+
213225
exports._internals = { IsMaster, GetCompanyId, GetCompanyIdByCustomerId };

app/controllers/invoicecontroller.js

Lines changed: 12 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 { makeBulkCreateIndirect } = require('./_bulk-helpers.js');
910
const Invoice = db.Invoice;
1011

1112
const IsMaster = auth.isMaster;
@@ -211,4 +212,15 @@ exports.remove = async (req, res) => {
211212
}
212213
};
213214

215+
exports.bulkCreate = makeBulkCreateIndirect({
216+
Model: Invoice,
217+
modelKey: 'Invoice',
218+
parentFkField: 'invCustId',
219+
resolveParentCompanyId: auth.getCompanyIdByCustomerId,
220+
allowedFields: ALLOWED_FIELDS_CREATE,
221+
archField: 'invArch',
222+
bodyKey: 'invoices',
223+
createdKey: 'invoices',
224+
});
225+
214226
exports._internals = { IsMaster, GetCompanyId, GetCompanyIdByCustomerId };

app/controllers/invoicejobcontroller.js

Lines changed: 12 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 { makeBulkCreateIndirect } = require('./_bulk-helpers.js');
1415
const InvoiceJob = db.InvoiceJob;
1516

1617
const IsMaster = auth.isMaster;
@@ -229,4 +230,15 @@ exports.remove = async (req, res) => {
229230
}
230231
};
231232

233+
exports.bulkCreate = makeBulkCreateIndirect({
234+
Model: InvoiceJob,
235+
modelKey: 'InvoiceJob',
236+
parentFkField: 'injbJobId',
237+
resolveParentCompanyId: auth.getCompanyIdByJobId,
238+
allowedFields: ALLOWED_FIELDS_CREATE,
239+
archField: 'injbArch',
240+
bodyKey: 'invoiceJobs',
241+
createdKey: 'invoiceJobs',
242+
});
243+
232244
exports._internals = { IsMaster, GetCompanyId, GetCompanyIdByJobId };

app/controllers/jobcontroller.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const db = require('../config/db.config.js');
1313
const log = require('../config/logger.js');
1414
const auth = require('../middleware/auth.js');
1515
const { buildLinkHeader } = require('../middleware/pagination.js');
16+
const { makeBulkCreateIndirect } = require('./_bulk-helpers.js');
1617
const Job = db.Job;
1718

1819
const IsMaster = auth.isMaster;
@@ -219,4 +220,15 @@ exports.remove = async (req, res) => {
219220
}
220221
};
221222

223+
exports.bulkCreate = makeBulkCreateIndirect({
224+
Model: Job,
225+
modelKey: 'Job',
226+
parentFkField: 'jobCustId',
227+
resolveParentCompanyId: auth.getCompanyIdByCustomerId,
228+
allowedFields: ALLOWED_FIELDS_CREATE,
229+
archField: 'jobArch',
230+
bodyKey: 'jobs',
231+
createdKey: 'jobs',
232+
});
233+
222234
exports._internals = { IsMaster, GetCompanyId, GetCompanyIdByCustomerId };

app/controllers/productentrycontroller.js

Lines changed: 12 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 { makeBulkCreateIndirect } = require('./_bulk-helpers.js');
910
const ProductEntry = db.ProductEntry;
1011

1112
const IsMaster = auth.isMaster;
@@ -207,4 +208,15 @@ exports.remove = async (req, res) => {
207208
}
208209
};
209210

211+
exports.bulkCreate = makeBulkCreateIndirect({
212+
Model: ProductEntry,
213+
modelKey: 'ProductEntry',
214+
parentFkField: 'pentJobId',
215+
resolveParentCompanyId: auth.getCompanyIdByJobId,
216+
allowedFields: ALLOWED_FIELDS_CREATE,
217+
archField: 'penArch',
218+
bodyKey: 'productEntries',
219+
createdKey: 'productEntries',
220+
});
221+
210222
exports._internals = { IsMaster, GetCompanyId, GetCompanyIdByJobId };

app/controllers/purchaseorderheadercontroller.js

Lines changed: 12 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 { makeBulkCreateIndirect } = require('./_bulk-helpers.js');
1415
const PurchaseOrderHeader = db.PurchaseOrderHeader;
1516

1617
const IsMaster = auth.isMaster;
@@ -210,4 +211,15 @@ exports.remove = async (req, res) => {
210211
}
211212
};
212213

214+
exports.bulkCreate = makeBulkCreateIndirect({
215+
Model: PurchaseOrderHeader,
216+
modelKey: 'PurchaseOrderHeader',
217+
parentFkField: 'pohPovId',
218+
resolveParentCompanyId: auth.getCompanyIdByPovId,
219+
allowedFields: ALLOWED_FIELDS_CREATE,
220+
archField: 'pohArch',
221+
bodyKey: 'purchaseOrderHeaders',
222+
createdKey: 'purchaseOrderHeaders',
223+
});
224+
213225
exports._internals = { IsMaster, GetCompanyId, GetCompanyIdByPovId };

app/controllers/purchaseorderlinecontroller.js

Lines changed: 12 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 { makeBulkCreateIndirect } = require('./_bulk-helpers.js');
1415
const PurchaseOrderLine = db.PurchaseOrderLine;
1516

1617
const IsMaster = auth.isMaster;
@@ -210,4 +211,15 @@ exports.remove = async (req, res) => {
210211
}
211212
};
212213

214+
exports.bulkCreate = makeBulkCreateIndirect({
215+
Model: PurchaseOrderLine,
216+
modelKey: 'PurchaseOrderLine',
217+
parentFkField: 'polpoh',
218+
resolveParentCompanyId: auth.getCompanyIdByPohId,
219+
allowedFields: ALLOWED_FIELDS_CREATE,
220+
archField: 'polArch',
221+
bodyKey: 'purchaseOrderLines',
222+
createdKey: 'purchaseOrderLines',
223+
});
224+
213225
exports._internals = { IsMaster, GetCompanyId, GetCompanyIdByPohId };

0 commit comments

Comments
 (0)