Skip to content

Commit cf48e52

Browse files
CryptoJonesAaron K. Clarkclaude
authored
feat(api): full-CRUD endpoints for 10 entities + PurchaseOrder migration (#39)
Expands the v1 surface from 7 paths to 35, exposing every entity in the original BACPAC schema that wasn't already wired up. New entities (full CRUD: POST, GET /:id, list-by-parent, PATCH, DELETE): - Worker, Company, BillingType, InventoryItem (direct compId) - Job, Invoice, CustomerPayment (customer-scoped) - InvoiceJob, ProductEntry (job-scoped) - VersionInfo (global, master-mutates) Three auth-scoping patterns, all routed through middleware/auth.js: - direct — entity has its own xxCompId column (Worker/BT/Inv). - customer — auth resolves via new getCompanyIdByCustomerId() helper (Job/Invoice/CustomerPayment). - job — auth resolves via new getCompanyIdByJobId() helper (InvoiceJob/ProductEntry). Specials: - Company has compId == its own id; POST/DELETE/list are master-only, GET/PATCH let non-master keys read/edit their own row. - VersionInfo has no compId and no archive column; reads are open to any authKey, mutations are master-only, DELETE is a hard destroy. New migration 20260517000000-purchase-orders-and-archive-columns adds the four BACPAC tables omitted from setup/TimeTracker.sql (PurchaseOrderHeaders/Lines/Vendors, InventoryTransactions) and retrofits the missing invitArch + injbArch columns the new soft-delete logic depends on. Tests: 24 files / 167 passing (was 15 / 93). Co-authored-by: Aaron K. Clark <akclark@thenetwerk.net> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6a32da1 commit cf48e52

45 files changed

Lines changed: 4788 additions & 0 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

app/config/db.config.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,5 +23,15 @@ db.Customer = require('../models/customer.model.js')(sequelize, Sequelize);
2323
db.ApiMaster = require('../models/apimaster.model.js')(sequelize, Sequelize);
2424
db.ApiKey = require('../models/apikey.model.js')(sequelize, Sequelize);
2525
db.TimeEntry = require('../models/timeentry.model.js')(sequelize, Sequelize);
26+
db.Worker = require('../models/worker.model.js')(sequelize, Sequelize);
27+
db.BillingType = require('../models/billingtype.model.js')(sequelize, Sequelize);
28+
db.InventoryItem = require('../models/inventoryitem.model.js')(sequelize, Sequelize);
29+
db.Company = require('../models/company.model.js')(sequelize, Sequelize);
30+
db.Job = require('../models/job.model.js')(sequelize, Sequelize);
31+
db.Invoice = require('../models/invoice.model.js')(sequelize, Sequelize);
32+
db.CustomerPayment = require('../models/customerpayment.model.js')(sequelize, Sequelize);
33+
db.InvoiceJob = require('../models/invoicejob.model.js')(sequelize, Sequelize);
34+
db.ProductEntry = require('../models/productentry.model.js')(sequelize, Sequelize);
35+
db.VersionInfo = require('../models/versioninfo.model.js')(sequelize, Sequelize);
2636

2737
module.exports = db;

app/config/openapi.js

Lines changed: 445 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// Copyright 2026 Aaron K. Clark
3+
"use strict";
4+
5+
const db = require('../config/db.config.js');
6+
const log = require('../config/logger.js');
7+
const auth = require('../middleware/auth.js');
8+
const BillingType = db.BillingType;
9+
10+
const IsMaster = auth.isMaster;
11+
const GetCompanyId = auth.getCompanyId;
12+
13+
const ALLOWED_FIELDS_CREATE = ['btName', 'btHourlyRate', 'btCompId'];
14+
const ALLOWED_FIELDS_UPDATE = ['btName', 'btHourlyRate'];
15+
16+
exports.create = async (req, res) => {
17+
const authKey = req.get('authKey');
18+
if (!authKey) {
19+
return res.status(403).json({ message: "Authorization key not sent." });
20+
}
21+
22+
let isAuthKeyMasterKey;
23+
try {
24+
isAuthKeyMasterKey = await IsMaster(authKey);
25+
} catch (error) {
26+
log.error({ err: error }, 'IsMaster failed');
27+
return res.status(500).json({ message: "Error!", error: String(error) });
28+
}
29+
30+
const body = req.body || {};
31+
const payload = {};
32+
for (const f of ALLOWED_FIELDS_CREATE) {
33+
if (body[f] !== undefined) payload[f] = body[f];
34+
}
35+
36+
if (!isAuthKeyMasterKey) {
37+
let authKeyCompanyId;
38+
try {
39+
authKeyCompanyId = await GetCompanyId(authKey);
40+
} catch (error) {
41+
log.error({ err: error }, 'GetCompanyId failed');
42+
return res.status(500).json({ message: "Error!", error: String(error) });
43+
}
44+
if (authKeyCompanyId === -1) {
45+
return res.status(403).json({ message: "Invalid Authorization Key." });
46+
}
47+
if (payload.btCompId !== undefined && Number(payload.btCompId) !== authKeyCompanyId) {
48+
return res.status(403).json({
49+
message: "Cannot create a billing type for a company you do not belong to.",
50+
});
51+
}
52+
payload.btCompId = authKeyCompanyId;
53+
} else {
54+
if (payload.btCompId === undefined || Number(payload.btCompId) <= 0) {
55+
return res.status(400).json({
56+
message: "Master-key requests must specify btCompId.",
57+
});
58+
}
59+
}
60+
61+
payload.btArch = false;
62+
63+
try {
64+
const created = await BillingType.create(payload);
65+
return res.status(201).json({ message: "Billing type created.", billingType: created });
66+
} catch (error) {
67+
log.error({ err: error }, 'BillingType.create failed');
68+
return res.status(500).json({ message: "Error!", error: String(error) });
69+
}
70+
};
71+
72+
exports.getById = async (req, res) => {
73+
const authKey = req.get('authKey');
74+
if (!authKey) {
75+
return res.status(403).json({ message: "Authorization key not sent." });
76+
}
77+
78+
let billingType;
79+
try {
80+
billingType = await BillingType.findByPk(req.params.id);
81+
} catch (error) {
82+
log.error({ err: error }, 'BillingType.findByPk failed');
83+
return res.status(500).json({ message: "Error!", error: String(error) });
84+
}
85+
if (!billingType || billingType.btArch) {
86+
return res.status(404).json({ message: "Not found." });
87+
}
88+
89+
const isMaster = await IsMaster(authKey);
90+
if (!isMaster) {
91+
const companyId = await GetCompanyId(authKey);
92+
if (companyId === -1 || billingType.btCompId !== companyId) {
93+
return res.status(403).json({ message: "Invalid Authorization Key." });
94+
}
95+
}
96+
return res.status(200).json({ message: "Found.", billingType });
97+
};
98+
99+
exports.listByCompany = async (req, res) => {
100+
const authKey = req.get('authKey');
101+
if (!authKey) {
102+
return res.status(403).json({ message: "Authorization key not sent." });
103+
}
104+
105+
const targetCompanyId = Number(req.params.id);
106+
if (!Number.isInteger(targetCompanyId) || targetCompanyId <= 0) {
107+
return res.status(400).json({ message: "Invalid company id." });
108+
}
109+
110+
const isMaster = await IsMaster(authKey);
111+
if (!isMaster) {
112+
const companyId = await GetCompanyId(authKey);
113+
if (companyId === -1 || companyId !== targetCompanyId) {
114+
return res.status(403).json({ message: "Invalid Authorization Key." });
115+
}
116+
}
117+
118+
const requestedLimit = parseInt(req.query.limit, 10);
119+
const limit = Number.isInteger(requestedLimit) && requestedLimit > 0
120+
? Math.min(requestedLimit, 500)
121+
: 100;
122+
const requestedOffset = parseInt(req.query.offset, 10);
123+
const offset = Number.isInteger(requestedOffset) && requestedOffset >= 0
124+
? requestedOffset
125+
: 0;
126+
127+
try {
128+
const { count, rows } = await BillingType.findAndCountAll({
129+
where: { btCompId: targetCompanyId, btArch: false },
130+
limit,
131+
offset,
132+
order: [['btId', 'ASC']],
133+
});
134+
return res.status(200).json({
135+
message: "Successfully retrieved billing types with CompanyId " + targetCompanyId,
136+
count,
137+
limit,
138+
offset,
139+
billingTypes: rows,
140+
});
141+
} catch (error) {
142+
log.error({ err: error }, 'BillingType.findAndCountAll failed');
143+
return res.status(500).json({ message: "Error!", error: String(error) });
144+
}
145+
};
146+
147+
exports.update = async (req, res) => {
148+
const authKey = req.get('authKey');
149+
if (!authKey) {
150+
return res.status(403).json({ message: "Authorization key not sent." });
151+
}
152+
153+
let billingType;
154+
try {
155+
billingType = await BillingType.findByPk(req.params.id);
156+
} catch (error) {
157+
log.error({ err: error }, 'BillingType.findByPk failed');
158+
return res.status(500).json({ message: "Error!", error: String(error) });
159+
}
160+
if (!billingType || billingType.btArch) {
161+
return res.status(404).json({ message: "Not found." });
162+
}
163+
164+
const isMaster = await IsMaster(authKey);
165+
if (!isMaster) {
166+
const companyId = await GetCompanyId(authKey);
167+
if (companyId === -1 || billingType.btCompId !== companyId) {
168+
return res.status(403).json({ message: "Invalid Authorization Key." });
169+
}
170+
}
171+
172+
const body = req.body || {};
173+
const updates = {};
174+
for (const f of ALLOWED_FIELDS_UPDATE) {
175+
if (body[f] !== undefined) updates[f] = body[f];
176+
}
177+
if (Object.keys(updates).length === 0) {
178+
return res.status(400).json({ message: "No updatable fields supplied." });
179+
}
180+
181+
try {
182+
await billingType.update(updates);
183+
return res.status(200).json({ message: "Updated.", billingType });
184+
} catch (error) {
185+
log.error({ err: error }, 'BillingType.update failed');
186+
return res.status(500).json({ message: "Error!", error: String(error) });
187+
}
188+
};
189+
190+
exports.remove = async (req, res) => {
191+
const authKey = req.get('authKey');
192+
if (!authKey) {
193+
return res.status(403).json({ message: "Authorization key not sent." });
194+
}
195+
196+
let billingType;
197+
try {
198+
billingType = await BillingType.findByPk(req.params.id);
199+
} catch (error) {
200+
log.error({ err: error }, 'BillingType.findByPk failed');
201+
return res.status(500).json({ message: "Error!", error: String(error) });
202+
}
203+
if (!billingType || billingType.btArch) {
204+
return res.status(404).json({ message: "Not found." });
205+
}
206+
207+
const isMaster = await IsMaster(authKey);
208+
if (!isMaster) {
209+
const companyId = await GetCompanyId(authKey);
210+
if (companyId === -1 || billingType.btCompId !== companyId) {
211+
return res.status(403).json({ message: "Invalid Authorization Key." });
212+
}
213+
}
214+
215+
try {
216+
await billingType.update({ btArch: true });
217+
return res.status(200).json({ message: "Archived.", id: billingType.btId });
218+
} catch (error) {
219+
log.error({ err: error }, 'BillingType archive failed');
220+
return res.status(500).json({ message: "Error!", error: String(error) });
221+
}
222+
};
223+
224+
exports._internals = { IsMaster, GetCompanyId };

0 commit comments

Comments
 (0)