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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

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

### Changed
- **`app/middleware/auth.js` is now testable end-to-end** (P5-M).
Two changes:
Expand Down
143 changes: 142 additions & 1 deletion app/controllers/_bulk-helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -127,4 +127,145 @@ function makeBulkCreate({
};
}

module.exports = { makeBulkCreate };
/**
* Sibling factory for entities that scope auth INDIRECTLY through
* a parent FK rather than carrying their own *CompId column.
*
* Examples:
* - Job (jobCustId → Customer.custCompId)
* - Invoice (invCustId → Customer.custCompId)
* - CustomerPayment (cpayCustId → Customer.custCompId)
* - InvoiceJob (injbJobId → Job → Customer.custCompId)
* - ProductEntry (pentJobId → Job → Customer.custCompId)
* - PurchaseOrderHeader (pohPovId → PurchaseOrderVendor.povCompId)
* - PurchaseOrderLine (polpoh → PurchaseOrderHeader → vendor)
*
* Config (additions vs makeBulkCreate):
* - parentFkField the column on each entry that names the
* parent row whose scope governs (e.g.
* 'jobCustId', 'pohPovId', 'polpoh')
* - resolveParentCompanyId(parentId)
* async function returning the int company
* id, or -1 if the parent is missing/archived
* /unresolved (e.g. auth.getCompanyIdByCustomerId).
* - compIdField NOT used here — kept off the signature so
* callers don't confuse this with the direct
* version. If the entity grows its own column
* later, switch to makeBulkCreate.
*
* Per-entry contract:
* - parentFkField is REQUIRED on every entry. We need it to
* resolve scope; absent it we can't authorize the entry
* (return 400 with the offending index).
* - For non-master keys: the resolved parent company must equal
* authKey's company, else 403 with the offending index. Catches
* cross-tenant smuggling attempts in a single batch.
* - For master keys: the parent must resolve (404-style 400 if
* not), but master keys aren't pinned to a company so any
* resolved company is fine.
*/
function makeBulkCreateIndirect({
Model,
modelKey,
parentFkField,
resolveParentCompanyId,
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.` });
}

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 + per-entry parent-FK scope check.
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];
}
const parentId = Number(p[parentFkField]);
if (!Number.isInteger(parentId) || parentId <= 0) {
return res.status(400).json({
message: `${bodyKey}[${i}]: ${parentFkField} is required.`,
});
}

let parentCompId;
try {
parentCompId = await resolveParentCompanyId(parentId);
} catch (error) {
log.error({ err: error }, `${modelKey}: parent scope resolve failed`);
return res.status(500).json({ message: "Error!", error: String(error) });
}
if (parentCompId === -1) {
return res.status(400).json({
message: `${bodyKey}[${i}]: parent row not found or archived.`,
});
}

if (!isAuthKeyMasterKey && parentCompId !== authKeyCompanyId) {
return res.status(403).json({
message: `${bodyKey}[${i}]: cannot create for a company you do not belong to.`,
});
}

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, makeBulkCreateIndirect };
12 changes: 12 additions & 0 deletions app/controllers/customerpaymentcontroller.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 { makeBulkCreateIndirect } = require('./_bulk-helpers.js');
const CustomerPayment = db.CustomerPayment;

const IsMaster = auth.isMaster;
Expand Down Expand Up @@ -210,4 +211,15 @@ exports.remove = async (req, res) => {
}
};

exports.bulkCreate = makeBulkCreateIndirect({
Model: CustomerPayment,
modelKey: 'CustomerPayment',
parentFkField: 'cpayCustId',
resolveParentCompanyId: auth.getCompanyIdByCustomerId,
allowedFields: ALLOWED_FIELDS_CREATE,
archField: 'cpayArch',
bodyKey: 'customerPayments',
createdKey: 'customerPayments',
});

exports._internals = { IsMaster, GetCompanyId, GetCompanyIdByCustomerId };
12 changes: 12 additions & 0 deletions app/controllers/invoicecontroller.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 { makeBulkCreateIndirect } = require('./_bulk-helpers.js');
const Invoice = db.Invoice;

const IsMaster = auth.isMaster;
Expand Down Expand Up @@ -211,4 +212,15 @@ exports.remove = async (req, res) => {
}
};

exports.bulkCreate = makeBulkCreateIndirect({
Model: Invoice,
modelKey: 'Invoice',
parentFkField: 'invCustId',
resolveParentCompanyId: auth.getCompanyIdByCustomerId,
allowedFields: ALLOWED_FIELDS_CREATE,
archField: 'invArch',
bodyKey: 'invoices',
createdKey: 'invoices',
});

exports._internals = { IsMaster, GetCompanyId, GetCompanyIdByCustomerId };
12 changes: 12 additions & 0 deletions app/controllers/invoicejobcontroller.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 { makeBulkCreateIndirect } = require('./_bulk-helpers.js');
const InvoiceJob = db.InvoiceJob;

const IsMaster = auth.isMaster;
Expand Down Expand Up @@ -229,4 +230,15 @@ exports.remove = async (req, res) => {
}
};

exports.bulkCreate = makeBulkCreateIndirect({
Model: InvoiceJob,
modelKey: 'InvoiceJob',
parentFkField: 'injbJobId',
resolveParentCompanyId: auth.getCompanyIdByJobId,
allowedFields: ALLOWED_FIELDS_CREATE,
archField: 'injbArch',
bodyKey: 'invoiceJobs',
createdKey: 'invoiceJobs',
});

exports._internals = { IsMaster, GetCompanyId, GetCompanyIdByJobId };
12 changes: 12 additions & 0 deletions app/controllers/jobcontroller.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,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 { makeBulkCreateIndirect } = require('./_bulk-helpers.js');
const Job = db.Job;

const IsMaster = auth.isMaster;
Expand Down Expand Up @@ -219,4 +220,15 @@ exports.remove = async (req, res) => {
}
};

exports.bulkCreate = makeBulkCreateIndirect({
Model: Job,
modelKey: 'Job',
parentFkField: 'jobCustId',
resolveParentCompanyId: auth.getCompanyIdByCustomerId,
allowedFields: ALLOWED_FIELDS_CREATE,
archField: 'jobArch',
bodyKey: 'jobs',
createdKey: 'jobs',
});

exports._internals = { IsMaster, GetCompanyId, GetCompanyIdByCustomerId };
12 changes: 12 additions & 0 deletions app/controllers/productentrycontroller.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 { makeBulkCreateIndirect } = require('./_bulk-helpers.js');
const ProductEntry = db.ProductEntry;

const IsMaster = auth.isMaster;
Expand Down Expand Up @@ -207,4 +208,15 @@ exports.remove = async (req, res) => {
}
};

exports.bulkCreate = makeBulkCreateIndirect({
Model: ProductEntry,
modelKey: 'ProductEntry',
parentFkField: 'pentJobId',
resolveParentCompanyId: auth.getCompanyIdByJobId,
allowedFields: ALLOWED_FIELDS_CREATE,
archField: 'penArch',
bodyKey: 'productEntries',
createdKey: 'productEntries',
});

exports._internals = { IsMaster, GetCompanyId, GetCompanyIdByJobId };
12 changes: 12 additions & 0 deletions app/controllers/purchaseorderheadercontroller.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 { makeBulkCreateIndirect } = require('./_bulk-helpers.js');
const PurchaseOrderHeader = db.PurchaseOrderHeader;

const IsMaster = auth.isMaster;
Expand Down Expand Up @@ -210,4 +211,15 @@ exports.remove = async (req, res) => {
}
};

exports.bulkCreate = makeBulkCreateIndirect({
Model: PurchaseOrderHeader,
modelKey: 'PurchaseOrderHeader',
parentFkField: 'pohPovId',
resolveParentCompanyId: auth.getCompanyIdByPovId,
allowedFields: ALLOWED_FIELDS_CREATE,
archField: 'pohArch',
bodyKey: 'purchaseOrderHeaders',
createdKey: 'purchaseOrderHeaders',
});

exports._internals = { IsMaster, GetCompanyId, GetCompanyIdByPovId };
12 changes: 12 additions & 0 deletions app/controllers/purchaseorderlinecontroller.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 { makeBulkCreateIndirect } = require('./_bulk-helpers.js');
const PurchaseOrderLine = db.PurchaseOrderLine;

const IsMaster = auth.isMaster;
Expand Down Expand Up @@ -210,4 +211,15 @@ exports.remove = async (req, res) => {
}
};

exports.bulkCreate = makeBulkCreateIndirect({
Model: PurchaseOrderLine,
modelKey: 'PurchaseOrderLine',
parentFkField: 'polpoh',
resolveParentCompanyId: auth.getCompanyIdByPohId,
allowedFields: ALLOWED_FIELDS_CREATE,
archField: 'polArch',
bodyKey: 'purchaseOrderLines',
createdKey: 'purchaseOrderLines',
});

exports._internals = { IsMaster, GetCompanyId, GetCompanyIdByPohId };
Loading
Loading