Skip to content

Commit 6be367a

Browse files
CryptoJonesAaron K. Clarkclaude
authored
feat(api): PurchaseOrderVendor endpoints (1 of 4 newly-migrated tables) (#50)
Full CRUD for the vendors a Company issues purchase orders to. POST /v1/purchaseordervendor GET /v1/purchaseordervendor/:id GET /v1/purchaseordervendor/bycompany/:id (paginated) PATCH /v1/purchaseordervendor/:id DELETE /v1/purchaseordervendor/:id (soft-delete via povArch) Direct compId scoping via povCompId — same auth shape as Worker/BillingType/InventoryItem. Schema whitelist enforced by zod at the middleware boundary AND a server-side ALLOWED_FIELDS allowlist in the controller (mass-assignment defense in depth). povCompId is not patchable post-create (would amount to moving a vendor between companies, which would break auth invariants). This is 1 of 4 endpoints for the tables added by the 20260517000000-purchase-orders-and-archive-columns migration. Vendors first because PurchaseOrderHeaders FK-references this table; headers and lines will follow in their own PR. The fourth table, InventoryTransactions, is unrelated and gets its own PR too. Tests: 25 files / 175 tests (was 24 / 167). Co-authored-by: Aaron K. Clark <akclark@thenetwerk.net> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 583241f commit 6be367a

7 files changed

Lines changed: 482 additions & 0 deletions

File tree

app/config/db.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,5 +33,6 @@ db.CustomerPayment = require('../models/customerpayment.model.js')(sequelize, Se
3333
db.InvoiceJob = require('../models/invoicejob.model.js')(sequelize, Sequelize);
3434
db.ProductEntry = require('../models/productentry.model.js')(sequelize, Sequelize);
3535
db.VersionInfo = require('../models/versioninfo.model.js')(sequelize, Sequelize);
36+
db.PurchaseOrderVendor = require('../models/purchaseordervendor.model.js')(sequelize, Sequelize);
3637

3738
module.exports = db;

app/config/openapi.js

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,30 @@ const versionInfoSchema = {
171171
},
172172
};
173173

174+
const purchaseOrderVendorSchema = {
175+
type: 'object',
176+
properties: {
177+
povId: { type: 'integer', readOnly: true },
178+
povName: { type: 'string' },
179+
povMailingAddress1: { type: 'string' },
180+
povMailingAddress2: { type: 'string' },
181+
povMailingCity: { type: 'string' },
182+
povMailingState: { type: 'string' },
183+
povMailingCountry: { type: 'string' },
184+
povMailingZip: { type: 'string' },
185+
povBillingAddress1: { type: 'string' },
186+
povBillingAddress2: { type: 'string' },
187+
povBillingCity: { type: 'string' },
188+
povBillingState: { type: 'string' },
189+
povBillingCountry: { type: 'string' },
190+
povBillingZip: { type: 'string' },
191+
povPhone: { type: 'string' },
192+
povEMail: { type: 'string', format: 'email' },
193+
povCompId: { type: 'integer' },
194+
povArch: { type: 'boolean', readOnly: true },
195+
},
196+
};
197+
174198
const timeEntrySchema = {
175199
type: 'object',
176200
properties: {
@@ -220,6 +244,7 @@ const spec = {
220244
InvoiceJob: invoiceJobSchema,
221245
ProductEntry: productEntrySchema,
222246
VersionInfo: versionInfoSchema,
247+
PurchaseOrderVendor: purchaseOrderVendorSchema,
223248
Error: errorResponse,
224249
},
225250
},
@@ -664,6 +689,31 @@ const spec = {
664689
patch: { summary: 'Partial update of a version info (master keys only)', security: [{ authKey: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }], requestBody: { content: { 'application/json': { schema: { $ref: '#/components/schemas/VersionInfo' } } } }, responses: { 200: { description: 'Updated' }, 400: { description: 'No updatable fields supplied' }, 404: { description: 'Not found' }, 403: { description: 'Non-master key' } } },
665690
delete: { summary: 'Hard-delete a version info (master keys only — no archive column on this table)', security: [{ authKey: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }], responses: { 200: { description: 'Deleted' }, 404: { description: 'Not found' }, 403: { description: 'Non-master key' } } },
666691
},
692+
'/v1/purchaseordervendor': {
693+
post: {
694+
summary: 'Create a PO vendor',
695+
security: [{ authKey: [] }],
696+
requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/PurchaseOrderVendor' } } } },
697+
responses: { 201: { description: 'Created' }, 400: { description: 'Bad request' }, 403: { description: 'Auth failure' } },
698+
},
699+
},
700+
'/v1/purchaseordervendor/{id}': {
701+
get: { summary: 'Get one PO vendor', security: [{ authKey: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }], responses: { 200: { description: 'Found' }, 404: { description: 'Not found' }, 403: { description: 'Auth failure' } } },
702+
patch: { summary: 'Partial update of a PO vendor', security: [{ authKey: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }], requestBody: { content: { 'application/json': { schema: { $ref: '#/components/schemas/PurchaseOrderVendor' } } } }, responses: { 200: { description: 'Updated' }, 400: { description: 'No updatable fields supplied' }, 404: { description: 'Not found' }, 403: { description: 'Auth failure' } } },
703+
delete: { summary: 'Soft-delete a PO vendor', security: [{ authKey: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }], responses: { 200: { description: 'Archived' }, 404: { description: 'Not found' }, 403: { description: 'Auth failure' } } },
704+
},
705+
'/v1/purchaseordervendor/bycompany/{id}': {
706+
get: {
707+
summary: 'List PO vendors in a company (paginated)',
708+
security: [{ authKey: [] }],
709+
parameters: [
710+
{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } },
711+
{ name: 'limit', in: 'query', schema: { type: 'integer', default: 100, maximum: 500 } },
712+
{ name: 'offset', in: 'query', schema: { type: 'integer', default: 0 } },
713+
],
714+
responses: { 200: { description: 'OK' }, 400: { description: 'Invalid company id' }, 403: { description: 'Auth failure' } },
715+
},
716+
},
667717
},
668718
};
669719

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// Copyright 2026 Aaron K. Clark
3+
"use strict";
4+
5+
/**
6+
* PurchaseOrderVendor controller — direct compId scoping via povCompId.
7+
* Same auth shape as Worker/BillingType/InventoryItem.
8+
*/
9+
10+
const db = require('../config/db.config.js');
11+
const log = require('../config/logger.js');
12+
const auth = require('../middleware/auth.js');
13+
const PurchaseOrderVendor = db.PurchaseOrderVendor;
14+
15+
const IsMaster = auth.isMaster;
16+
const GetCompanyId = auth.getCompanyId;
17+
18+
// The schema whitelist already validates fields; we re-state it here so
19+
// the controller doesn't trust the request body verbatim (mass-assignment
20+
// defense — a forged povId in the body wouldn't make it through this
21+
// allowlist even if the schema were bypassed).
22+
const ALLOWED_FIELDS_CREATE = [
23+
'povName', 'povMailingAddress1', 'povMailingAddress2', 'povMailingCity',
24+
'povMailingState', 'povMailingCountry', 'povMailingZip',
25+
'povBillingAddress1', 'povBillingAddress2', 'povBillingCity',
26+
'povBillingState', 'povBillingCountry', 'povBillingZip',
27+
'povPhone', 'povEMail', 'povCompId',
28+
];
29+
const ALLOWED_FIELDS_UPDATE = ALLOWED_FIELDS_CREATE.filter(f => f !== 'povCompId');
30+
31+
exports.create = async (req, res) => {
32+
const authKey = req.get('authKey');
33+
if (!authKey) {
34+
return res.status(403).json({ message: "Authorization key not sent." });
35+
}
36+
37+
let isAuthKeyMasterKey;
38+
try {
39+
isAuthKeyMasterKey = await IsMaster(authKey);
40+
} catch (error) {
41+
log.error({ err: error }, 'IsMaster failed');
42+
return res.status(500).json({ message: "Error!", error: String(error) });
43+
}
44+
45+
const body = req.body || {};
46+
const payload = {};
47+
for (const f of ALLOWED_FIELDS_CREATE) {
48+
if (body[f] !== undefined) payload[f] = body[f];
49+
}
50+
51+
if (!isAuthKeyMasterKey) {
52+
let authKeyCompanyId;
53+
try {
54+
authKeyCompanyId = await GetCompanyId(authKey);
55+
} catch (error) {
56+
log.error({ err: error }, 'GetCompanyId failed');
57+
return res.status(500).json({ message: "Error!", error: String(error) });
58+
}
59+
if (authKeyCompanyId === -1) {
60+
return res.status(403).json({ message: "Invalid Authorization Key." });
61+
}
62+
if (payload.povCompId !== undefined && Number(payload.povCompId) !== authKeyCompanyId) {
63+
return res.status(403).json({
64+
message: "Cannot create a PO vendor for a company you do not belong to.",
65+
});
66+
}
67+
payload.povCompId = authKeyCompanyId;
68+
} else {
69+
if (payload.povCompId === undefined || Number(payload.povCompId) <= 0) {
70+
return res.status(400).json({
71+
message: "Master-key requests must specify povCompId.",
72+
});
73+
}
74+
}
75+
76+
payload.povArch = false;
77+
78+
try {
79+
const created = await PurchaseOrderVendor.create(payload);
80+
return res.status(201).json({ message: "PO vendor created.", purchaseOrderVendor: created });
81+
} catch (error) {
82+
log.error({ err: error }, 'PurchaseOrderVendor.create failed');
83+
return res.status(500).json({ message: "Error!", error: String(error) });
84+
}
85+
};
86+
87+
exports.getById = async (req, res) => {
88+
const authKey = req.get('authKey');
89+
if (!authKey) {
90+
return res.status(403).json({ message: "Authorization key not sent." });
91+
}
92+
93+
let vendor;
94+
try {
95+
vendor = await PurchaseOrderVendor.findByPk(req.params.id);
96+
} catch (error) {
97+
log.error({ err: error }, 'PurchaseOrderVendor.findByPk failed');
98+
return res.status(500).json({ message: "Error!", error: String(error) });
99+
}
100+
if (!vendor || vendor.povArch) {
101+
return res.status(404).json({ message: "Not found." });
102+
}
103+
104+
const isMaster = await IsMaster(authKey);
105+
if (!isMaster) {
106+
const companyId = await GetCompanyId(authKey);
107+
if (companyId === -1 || vendor.povCompId !== companyId) {
108+
return res.status(403).json({ message: "Invalid Authorization Key." });
109+
}
110+
}
111+
return res.status(200).json({ message: "Found.", purchaseOrderVendor: vendor });
112+
};
113+
114+
exports.listByCompany = async (req, res) => {
115+
const authKey = req.get('authKey');
116+
if (!authKey) {
117+
return res.status(403).json({ message: "Authorization key not sent." });
118+
}
119+
120+
const targetCompanyId = Number(req.params.id);
121+
if (!Number.isInteger(targetCompanyId) || targetCompanyId <= 0) {
122+
return res.status(400).json({ message: "Invalid company id." });
123+
}
124+
125+
const isMaster = await IsMaster(authKey);
126+
if (!isMaster) {
127+
const companyId = await GetCompanyId(authKey);
128+
if (companyId === -1 || companyId !== targetCompanyId) {
129+
return res.status(403).json({ message: "Invalid Authorization Key." });
130+
}
131+
}
132+
133+
const requestedLimit = parseInt(req.query.limit, 10);
134+
const limit = Number.isInteger(requestedLimit) && requestedLimit > 0
135+
? Math.min(requestedLimit, 500)
136+
: 100;
137+
const requestedOffset = parseInt(req.query.offset, 10);
138+
const offset = Number.isInteger(requestedOffset) && requestedOffset >= 0
139+
? requestedOffset
140+
: 0;
141+
142+
try {
143+
const { count, rows } = await PurchaseOrderVendor.findAndCountAll({
144+
where: { povCompId: targetCompanyId, povArch: false },
145+
limit, offset,
146+
order: [['povId', 'ASC']],
147+
});
148+
return res.status(200).json({
149+
message: "Successfully retrieved PO vendors with CompanyId " + targetCompanyId,
150+
count, limit, offset, purchaseOrderVendors: rows,
151+
});
152+
} catch (error) {
153+
log.error({ err: error }, 'PurchaseOrderVendor.findAndCountAll failed');
154+
return res.status(500).json({ message: "Error!", error: String(error) });
155+
}
156+
};
157+
158+
exports.update = async (req, res) => {
159+
const authKey = req.get('authKey');
160+
if (!authKey) {
161+
return res.status(403).json({ message: "Authorization key not sent." });
162+
}
163+
164+
let vendor;
165+
try {
166+
vendor = await PurchaseOrderVendor.findByPk(req.params.id);
167+
} catch (error) {
168+
log.error({ err: error }, 'PurchaseOrderVendor.findByPk failed');
169+
return res.status(500).json({ message: "Error!", error: String(error) });
170+
}
171+
if (!vendor || vendor.povArch) {
172+
return res.status(404).json({ message: "Not found." });
173+
}
174+
175+
const isMaster = await IsMaster(authKey);
176+
if (!isMaster) {
177+
const companyId = await GetCompanyId(authKey);
178+
if (companyId === -1 || vendor.povCompId !== companyId) {
179+
return res.status(403).json({ message: "Invalid Authorization Key." });
180+
}
181+
}
182+
183+
const body = req.body || {};
184+
const updates = {};
185+
for (const f of ALLOWED_FIELDS_UPDATE) {
186+
if (body[f] !== undefined) updates[f] = body[f];
187+
}
188+
if (Object.keys(updates).length === 0) {
189+
return res.status(400).json({ message: "No updatable fields supplied." });
190+
}
191+
192+
try {
193+
await vendor.update(updates);
194+
return res.status(200).json({ message: "Updated.", purchaseOrderVendor: vendor });
195+
} catch (error) {
196+
log.error({ err: error }, 'PurchaseOrderVendor.update failed');
197+
return res.status(500).json({ message: "Error!", error: String(error) });
198+
}
199+
};
200+
201+
exports.remove = async (req, res) => {
202+
const authKey = req.get('authKey');
203+
if (!authKey) {
204+
return res.status(403).json({ message: "Authorization key not sent." });
205+
}
206+
207+
let vendor;
208+
try {
209+
vendor = await PurchaseOrderVendor.findByPk(req.params.id);
210+
} catch (error) {
211+
log.error({ err: error }, 'PurchaseOrderVendor.findByPk failed');
212+
return res.status(500).json({ message: "Error!", error: String(error) });
213+
}
214+
if (!vendor || vendor.povArch) {
215+
return res.status(404).json({ message: "Not found." });
216+
}
217+
218+
const isMaster = await IsMaster(authKey);
219+
if (!isMaster) {
220+
const companyId = await GetCompanyId(authKey);
221+
if (companyId === -1 || vendor.povCompId !== companyId) {
222+
return res.status(403).json({ message: "Invalid Authorization Key." });
223+
}
224+
}
225+
226+
try {
227+
await vendor.update({ povArch: true });
228+
return res.status(200).json({ message: "Archived.", id: vendor.povId });
229+
} catch (error) {
230+
log.error({ err: error }, 'PurchaseOrderVendor archive failed');
231+
return res.status(500).json({ message: "Error!", error: String(error) });
232+
}
233+
};
234+
235+
exports._internals = { IsMaster, GetCompanyId };
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// Copyright 2026 Aaron K. Clark
3+
"use strict";
4+
5+
/**
6+
* PurchaseOrderVendor — a vendor that POs are issued to. Has both a
7+
* mailing address (where invoices arrive) and a billing address
8+
* (where checks get sent). Direct company scoping via povCompId.
9+
* Soft-deletes via povArch.
10+
*/
11+
module.exports = (sequelize, Sequelize) => {
12+
const PurchaseOrderVendor = sequelize.define('PurchaseOrderVendor', {
13+
povId: {
14+
field: 'povId',
15+
type: Sequelize.INTEGER,
16+
autoIncrement: true,
17+
primaryKey: true,
18+
},
19+
povName: { field: 'povName', type: Sequelize.TEXT, allowNull: false },
20+
povMailingAddress1: { field: 'povMailingAddress1', type: Sequelize.TEXT, allowNull: false },
21+
povMailingAddress2: { field: 'povMailingAddress2', type: Sequelize.TEXT },
22+
povMailingCity: { field: 'povMailingCity', type: Sequelize.TEXT, allowNull: false },
23+
povMailingState: { field: 'povMailingState', type: Sequelize.TEXT },
24+
povMailingCountry: { field: 'povMailingCountry', type: Sequelize.TEXT },
25+
povMailingZip: { field: 'povMailingZip', type: Sequelize.TEXT },
26+
povBillingAddress1: { field: 'povBillingAddress1', type: Sequelize.TEXT },
27+
povBillingAddress2: { field: 'povBillingAddress2', type: Sequelize.TEXT },
28+
povBillingCity: { field: 'povBillingCity', type: Sequelize.TEXT },
29+
povBillingState: { field: 'povBillingState', type: Sequelize.TEXT },
30+
povBillingCountry: { field: 'povBillingCountry', type: Sequelize.TEXT },
31+
povBillingZip: { field: 'povBillingZip', type: Sequelize.TEXT },
32+
povPhone: { field: 'povPhone', type: Sequelize.TEXT },
33+
povEMail: { field: 'povEMail', type: Sequelize.TEXT },
34+
povCompId: { field: 'povCompId', type: Sequelize.INTEGER, allowNull: false },
35+
povArch: { field: 'povArch', type: Sequelize.BOOLEAN, defaultValue: false },
36+
}, {
37+
tableName: 'PurchaseOrderVendors',
38+
timestamps: false,
39+
});
40+
41+
return PurchaseOrderVendor;
42+
};

0 commit comments

Comments
 (0)