EverydayTech Platform - Developer Reference
Complete Source Code Documentation - All Applications
Loading...
Searching...
No Matches
purchaseOrders.js
Go to the documentation of this file.
1/**
2 * @file purchaseOrders.js
3 * @description Purchase order management for procurement and inventory tracking. Supports PO header/line item CRUD,
4 * status tracking, supplier management, and tenant isolation. All routes require authentication and enforce
5 * tenant context.
6 * @module routes/purchaseOrders
7 */
8
9/**
10 * @apiDefine PurchaseOrderGroup Purchase Orders
11 * Purchase order procurement and tracking
12 */
13
14const express = require('express');
15const router = express.Router();
16const pool = require('../services/db');
17const authenticateToken = require('../middleware/auth');
18const { getTenantFilter, setTenantContext } = require('../middleware/tenant');
19
20// Apply authentication and tenant context to all routes
21router.use(authenticateToken, setTenantContext);
22
23/**
24 * @api {get} /api/purchase-orders List Purchase Orders
25 * @apiName ListPurchaseOrders
26 * @apiGroup PurchaseOrderGroup
27 * @apiDescription Retrieves paginated list of purchase orders with tenant filtering. Supports search by PO number
28 * or supplier name, and status filtering. Returns total count for pagination.
29 * @apiParam (Query) {number} [page=1] Page number
30 * @apiParam (Query) {number} [limit=25] Results per page
31 * @apiParam (Query) {string} [status] Filter by status (draft, pending, approved, ordered, received, cancelled)
32 * @apiParam (Query) {string} [search] Search by PO number or supplier (fuzzy match)
33 * @apiSuccess {object[]} purchase_orders Array of purchase order objects
34 * @apiSuccess {number} purchase_orders.purchase_order_id PO database ID
35 * @apiSuccess {string} purchase_orders.po_number PO reference number
36 * @apiSuccess {string} purchase_orders.supplier Supplier name
37 * @apiSuccess {string} purchase_orders.status PO status
38 * @apiSuccess {string} purchase_orders.tenant_name Tenant name
39 * @apiSuccess {number} total Total count of matching POs
40 * @apiError 500 Server error
41 * @apiExample {curl} Example:
42 * curl "https://api.example.com/api/purchase-orders?page=1&limit=25&status=pending" \
43 * -H "Authorization: Bearer YOUR_TOKEN"
44 * @apiExample {json} Success-Response (200):
45 * {
46 * "purchase_orders": [{"purchase_order_id": 1, "po_number": "PO-2026-001", "supplier": "Acme Corp", "status": "pending"}],
47 * "total": 50
48 * }
49 */
50// GET /purchase-orders
51router.get('/', async (req, res) => {
52 try {
53 const { page = 1, limit = 25, status, search } = req.query;
54 const offset = (parseInt(page, 10) - 1) * parseInt(limit, 10);
55
56 const { clause: tenantClause, params: tenantParams, nextParamIndex } = getTenantFilter(req, 'po');
57 const params = [...tenantParams];
58 const where = [];
59 let paramIndex = nextParamIndex;
60
61 if (tenantClause) where.push(tenantClause);
62
63 if (status) {
64 params.push(status);
65 where.push(`po.status = $${paramIndex}`);
66 paramIndex++;
67 }
68 if (search && search.trim()) {
69 params.push('%' + search.trim() + '%');
70 where.push(`(po.po_number ILIKE $${paramIndex} OR po.supplier ILIKE $${paramIndex})`);
71 paramIndex++;
72 }
73
74 const whereClause = where.length ? 'WHERE ' + where.join(' AND ') : '';
75
76 const countRes = await pool.query(`SELECT COUNT(*) FROM purchase_orders po ${whereClause}`, params);
77 const total = parseInt(countRes.rows[0].count, 10);
78
79 params.push(limit, offset);
80 const resQ = await pool.query(
81 `SELECT po.*, t.name AS tenant_name FROM purchase_orders po
82 LEFT JOIN tenants t ON po.tenant_id = t.tenant_id
83 ${whereClause} ORDER BY po.created_at DESC LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
84 params
85 );
86 res.json({ purchase_orders: resQ.rows, total });
87 } catch (err) {
88 console.error('Error fetching purchase orders:', err);
89 res.status(500).json({ error: 'Server error' });
90 }
91});
92
93/**
94 * @api {get} /api/purchase-orders/:id Get Purchase Order
95 * @apiName GetPurchaseOrder
96 * @apiGroup PurchaseOrderGroup
97 * @apiDescription Retrieves purchase order header and line items by ID. Enforces tenant isolation.
98 * @apiParam {number} id Purchase order ID
99 * @apiSuccess {object} purchase_order PO header object
100 * @apiSuccess {number} purchase_order.purchase_order_id PO database ID
101 * @apiSuccess {string} purchase_order.po_number PO reference number
102 * @apiSuccess {string} purchase_order.supplier Supplier name
103 * @apiSuccess {string} purchase_order.bill_to Billing address
104 * @apiSuccess {string} purchase_order.ship_to Shipping address
105 * @apiSuccess {string} purchase_order.internal_notes Internal notes/comments
106 * @apiSuccess {string} purchase_order.status PO status
107 * @apiSuccess {Object[]} lines Array of line item objects
108 * @apiSuccess {number} lines.po_line_id Line item database ID
109 * @apiSuccess {number} lines.product_id Product database ID
110 * @apiSuccess {string} lines.sku Product SKU
111 * @apiSuccess {string} lines.description Line item description
112 * @apiSuccess {number} lines.qty_ordered Quantity ordered
113 * @apiSuccess {number} lines.unit_price Unit price
114 * @apiError 404 PO not found
115 * @apiError 500 Server error
116 * @apiExample {curl} Example:
117 * curl https://api.example.com/api/purchase-orders/1 \
118 * -H "Authorization: Bearer YOUR_TOKEN"
119 */
120// GET /purchase-orders/:id
121router.get('/:id', async (req, res) => {
122 const { id } = req.params;
123 try {
124 const { clause: tenantClause, params: tenantParams } = getTenantFilter(req, 'po');
125
126 let headerQuery = `SELECT po.* FROM purchase_orders po WHERE po.purchase_order_id = $1`;
127 let headerQueryParams = [id];
128
129 if (tenantClause) {
130 // Shift tenant param placeholders by +1 because $1 is already used by purchase_order_id
131 const adjustedClause = tenantClause.replace(/\$(\d+)/g, (m, n) => `$${parseInt(n, 10) + 1}`);
132 headerQuery += ` AND ${adjustedClause}`;
133 headerQueryParams.push(...tenantParams);
134 }
135
136 const header = await pool.query(headerQuery, headerQueryParams);
137 if (header.rows.length === 0) return res.status(404).json({ error: 'Not found' });
138
139 let linesQuery = `SELECT pol.* FROM purchase_order_lines pol WHERE pol.purchase_order_id = $1`;
140 let linesQueryParams = [id];
141
142 if (tenantClause) {
143 const tenantLinesClause = tenantClause.replace(/po\./g, 'pol.');
144 const adjustedLinesClause = tenantLinesClause.replace(/\$(\d+)/g, (m, n) => `$${parseInt(n, 10) + 1}`);
145 linesQuery += ` AND ${adjustedLinesClause}`;
146 linesQueryParams.push(...tenantParams);
147 }
148
149 const lines = await pool.query(linesQuery, linesQueryParams);
150 res.json({ purchase_order: header.rows[0], lines: lines.rows });
151 } catch (err) {
152 console.error('Error fetching purchase order:', err);
153 res.status(500).json({ error: 'Server error' });
154 }
155});
156
157/**
158 * @api {post} /api/purchase-orders Create Purchase Order
159 * @apiName CreatePurchaseOrder
160 * @apiGroup PurchaseOrderGroup
161 * @apiDescription Creates new purchase order with optional line items in atomic transaction. Default status is draft.
162 * @apiParam {string} [po_number] PO reference number (must be unique per tenant)
163 * @apiParam {string} [supplier] Supplier name
164 * @apiParam {string} [bill_to] Billing address
165 * @apiParam {string} [ship_to] Shipping address
166 * @apiParam {string} [internal_notes] Internal notes/comments
167 * @apiParam {string} [status=draft] PO status
168 * @apiParam {object[]} [lines] Array of line items to create
169 * @apiParam {number} [lines.product_id] Product database ID
170 * @apiParam {string} [lines.sku] Product SKU
171 * @apiParam {string} [lines.description] Line description
172 * @apiParam {number} [lines.qty_ordered=1] Quantity ordered
173 * @apiParam {number} [lines.unit_price=0] Unit price
174 * @apiSuccess (201) {Object} purchase_order Created PO header object
175 * @apiError 403 No tenant context
176 * @apiError 409 PO number already exists
177 * @apiError 500 Create failed
178 * @apiExample {curl} Example:
179 * curl -X POST https://api.example.com/api/purchase-orders \
180 * -H "Authorization: Bearer YOUR_TOKEN" \
181 * -H "Content-Type: application/json" \
182 * -d '{"po_number":"PO-2026-001", "supplier":"Acme", "lines":[{"sku":"ABC-123", "qty_ordered":10, "unit_price":25.50}]}'
183 */
184// POST /purchase-orders (create header + optional lines)
185router.post('/', async (req, res) => {
186 const {
187 po_number,
188 supplier,
189 bill_to,
190 ship_to,
191 internal_notes,
192 status = 'draft',
193 lines = []
194 } = req.body;
195
196 if (!req.tenant?.id) {
197 return res.status(403).json({ error: 'No tenant context' });
198 }
199
200 const client = await pool.connect();
201 try {
202 await client.query('BEGIN');
203 const insertHeader = await client.query(
204 `INSERT INTO purchase_orders (
205 tenant_id, po_number, supplier, bill_to, ship_to, internal_notes, status
206 ) VALUES ($1,$2,$3,$4,$5,$6,$7)
207 RETURNING *`,
208 [req.tenant.id, po_number || null, supplier || null, bill_to || null, ship_to || null, internal_notes || null, status]
209 );
210
211 const header = insertHeader.rows[0];
212
213 for (const line of lines) {
214 await client.query(
215 `INSERT INTO purchase_order_lines (
216 tenant_id, purchase_order_id, product_id, sku, description, qty_ordered, unit_price
217 ) VALUES ($1,$2,$3,$4,$5,$6,$7)` ,
218 [req.tenant.id, header.purchase_order_id, line.product_id || null, line.sku || null, line.description || null, parseInt(line.qty_ordered || 1, 10), parseFloat(line.unit_price || 0)]
219 );
220 }
221
222 await client.query('COMMIT');
223 res.status(201).json(header);
224 } catch (err) {
225 await client.query('ROLLBACK');
226 console.error('Error creating purchase order:', err);
227 if (err.code === '23505') {
228 return res.status(409).json({ error: 'PO number already exists' });
229 }
230 res.status(500).json({ error: 'Create failed' });
231 } finally {
232 client.release();
233 }
234});
235
236/**
237 * @api {put} /api/purchase-orders/:id Update Purchase Order Header
238 * @apiName UpdatePurchaseOrder
239 * @apiGroup PurchaseOrderGroup
240 * @apiDescription Updates purchase order header fields. Only provided fields are updated (COALESCE). Enforces tenant isolation.
241 * @apiParam {number} id Purchase order ID
242 * @apiParam {string} [po_number] PO reference number (must be unique)
243 * @apiParam {string} [supplier] Supplier name
244 * @apiParam {string} [bill_to] Billing address
245 * @apiParam {string} [ship_to] Shipping address
246 * @apiParam {string} [internal_notes] Internal notes
247 * @apiParam {string} [status] PO status
248 * @apiSuccess {object} purchase_order Updated PO header object
249 * @apiError 404 PO not found
250 * @apiError 409 PO number already exists
251 * @apiError 500 Update failed
252 * @apiExample {curl} Example:
253 * curl -X PUT https://api.example.com/api/purchase-orders/1 \
254 * -H "Authorization: Bearer YOUR_TOKEN" \
255 * -H "Content-Type: application/json" \
256 * -d '{"status":"approved"}'
257 */
258// PUT /purchase-orders/:id (update header)
259router.put('/:id', async (req, res) => {
260 const { id } = req.params;
261 const { po_number, supplier, bill_to, ship_to, internal_notes, status } = req.body;
262 try {
263 const result = await pool.query(
264 `UPDATE purchase_orders
265 SET po_number = COALESCE($1, po_number),
266 supplier = COALESCE($2, supplier),
267 bill_to = COALESCE($3, bill_to),
268 ship_to = COALESCE($4, ship_to),
269 internal_notes = COALESCE($5, internal_notes),
270 status = COALESCE($6, status),
271 updated_at = NOW()
272 WHERE purchase_order_id = $7 AND tenant_id = $8
273 RETURNING *`,
274 [po_number || null, supplier || null, bill_to || null, ship_to || null, internal_notes || null, status || null, id, req.tenant.id]
275 );
276 if (result.rows.length === 0) return res.status(404).json({ error: 'Not found' });
277 res.json(result.rows[0]);
278 } catch (err) {
279 console.error('Error updating purchase order:', err);
280 if (err.code === '23505') {
281 return res.status(409).json({ error: 'PO number already exists' });
282 }
283 res.status(500).json({ error: 'Update failed' });
284 }
285});
286
287/**
288 * @api {post} /api/purchase-orders/:id/lines Add Line Item
289 * @apiName AddPurchaseOrderLine
290 * @apiGroup PurchaseOrderGroup
291 * @apiDescription Adds new line item to existing purchase order. Verifies PO belongs to tenant.
292 * @apiParam {number} id Purchase order ID
293 * @apiParam {number} [product_id] Product database ID
294 * @apiParam {string} [sku] Product SKU
295 * @apiParam {string} [description] Line description
296 * @apiParam {number} [qty_ordered=1] Quantity ordered
297 * @apiParam {number} [unit_price=0] Unit price
298 * @apiSuccess (201) {Object} line Created line item object
299 * @apiError 404 PO not found
300 * @apiError 500 Create failed
301 * @apiExample {curl} Example:
302 * curl -X POST https://api.example.com/api/purchase-orders/1/lines \
303 * -H "Authorization: Bearer YOUR_TOKEN" \
304 * -H "Content-Type: application/json" \
305 * -d '{"sku":"XYZ-789", "description":"Widget", "qty_ordered":5, "unit_price":15.00}'
306 */
307// POST /purchase-orders/:id/lines (add line)
308router.post('/:id/lines', async (req, res) => {
309 const { id } = req.params;
310 const { product_id, sku, description, qty_ordered, unit_price } = req.body;
311 try {
312 // Ensure header belongs to tenant
313 const check = await pool.query(`SELECT 1 FROM purchase_orders WHERE purchase_order_id = $1 AND tenant_id = $2`, [id, req.tenant.id]);
314 if (check.rows.length === 0) return res.status(404).json({ error: 'PO not found' });
315
316 const result = await pool.query(
317 `INSERT INTO purchase_order_lines (tenant_id, purchase_order_id, product_id, sku, description, qty_ordered, unit_price)
318 VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`,
319 [req.tenant.id, id, product_id || null, sku || null, description || null, parseInt(qty_ordered || 1, 10), parseFloat(unit_price || 0)]
320 );
321 res.status(201).json(result.rows[0]);
322 } catch (err) {
323 console.error('Error adding PO line:', err);
324 res.status(500).json({ error: 'Create failed' });
325 }
326});
327
328/**
329 * @api {put} /api/purchase-orders/:id/lines/:lineId Update Line Item
330 * @apiName UpdatePurchaseOrderLine
331 * @apiGroup PurchaseOrderGroup
332 * @apiDescription Updates existing purchase order line item. Verifies line belongs to PO and tenant.
333 * @apiParam {number} id Purchase order ID
334 * @apiParam {number} lineId Line item ID
335 * @apiParam {number} [product_id] Product database ID
336 * @apiParam {string} [sku] Product SKU
337 * @apiParam {string} [description] Line description
338 * @apiParam {number} [qty_ordered=1] Quantity ordered
339 * @apiParam {number} [unit_price=0] Unit price
340 * @apiSuccess {object} line Updated line item object
341 * @apiError 404 Line not found
342 * @apiError 500 Update failed
343 * @apiExample {curl} Example:
344 * curl -X PUT https://api.example.com/api/purchase-orders/1/lines/5 \
345 * -H "Authorization: Bearer YOUR_TOKEN" \
346 * -H "Content-Type: application/json" \
347 * -d '{"qty_ordered":10}'
348 */
349// PUT /purchase-orders/:id/lines/:lineId (update line)
350router.put('/:id/lines/:lineId', async (req, res) => {
351 const { id, lineId } = req.params;
352 const { product_id, sku, description, qty_ordered, unit_price } = req.body;
353 try {
354 // Ensure line belongs to tenant & header
355 const check = await pool.query(`SELECT 1 FROM purchase_order_lines WHERE po_line_id = $1 AND purchase_order_id = $2 AND tenant_id = $3`, [lineId, id, req.tenant.id]);
356 if (check.rows.length === 0) return res.status(404).json({ error: 'Line not found' });
357
358 const result = await pool.query(
359 `UPDATE purchase_order_lines
360 SET product_id = $1,
361 sku = $2,
362 description = $3,
363 qty_ordered = $4,
364 unit_price = $5,
365 updated_at = NOW()
366 WHERE po_line_id = $6 AND tenant_id = $7
367 RETURNING *`,
368 [product_id || null, sku || null, description || null, parseInt(qty_ordered || 1, 10), parseFloat(unit_price || 0), lineId, req.tenant.id]
369 );
370 res.json(result.rows[0]);
371 } catch (err) {
372 console.error('Error updating PO line:', err);
373 res.status(500).json({ error: 'Update failed' });
374 }
375});
376
377/**
378 * @api {delete} /api/purchase-orders/:id/lines/:lineId Delete Line Item
379 * @apiName DeletePurchaseOrderLine
380 * @apiGroup PurchaseOrderGroup
381 * @apiDescription Deletes purchase order line item. Verifies line belongs to PO and tenant.
382 * @apiParam {number} id Purchase order ID
383 * @apiParam {number} lineId Line item ID
384 * @apiSuccess (204) Empty No content (successful deletion)
385 * @apiError 404 Line not found
386 * @apiError 500 Delete failed
387 * @apiExample {curl} Example:
388 * curl -X DELETE https://api.example.com/api/purchase-orders/1/lines/5 \
389 * -H "Authorization: Bearer YOUR_TOKEN"
390 */
391// DELETE /purchase-orders/:id/lines/:lineId
392router.delete('/:id/lines/:lineId', async (req, res) => {
393 const { id, lineId } = req.params;
394 try {
395 const del = await pool.query(`DELETE FROM purchase_order_lines WHERE po_line_id = $1 AND purchase_order_id = $2 AND tenant_id = $3`, [lineId, id, req.tenant.id]);
396 if (del.rowCount === 0) return res.status(404).json({ error: 'Line not found' });
397 res.sendStatus(204);
398 } catch (err) {
399 console.error('Error deleting PO line:', err);
400 res.status(500).json({ error: 'Delete failed' });
401 }
402});
403
404/**
405 * @api {delete} /api/purchase-orders/:id Delete Purchase Order
406 * @apiName DeletePurchaseOrder
407 * @apiGroup PurchaseOrderGroup
408 * @apiDescription Deletes purchase order and all associated line items in atomic transaction. **Requires admin role.**
409 * MSP users can delete from any tenant.
410 * @apiParam {number} id Purchase order ID
411 * @apiSuccess (204) Empty No content (successful deletion)
412 * @apiError 403 Admin privileges required
413 * @apiError 404 PO not found
414 * @apiError 500 Delete failed
415 * @apiExample {curl} Example:
416 * curl -X DELETE https://api.example.com/api/purchase-orders/1 \
417 * -H "Authorization: Bearer YOUR_TOKEN"
418 */
419// DELETE /purchase-orders/:id (admin only)
420router.delete('/:id', async (req, res) => {
421 const { id } = req.params;
422
423 // Restrict delete to admin users only
424 if (!req.user || req.user.role !== 'admin') {
425 return res.status(403).json({ error: 'Admin privileges required to delete purchase orders' });
426 }
427
428 const client = await pool.connect();
429 try {
430 await client.query('BEGIN');
431
432 // Get tenant filter - MSP users can delete from any tenant
433 const { clause: tenantClause, params: tenantParams } = getTenantFilter(req, 'po');
434
435 // Build query to verify PO exists
436 let checkQuery = `SELECT purchase_order_id, tenant_id FROM purchase_orders po WHERE po.purchase_order_id = $1`;
437 let checkParams = [id];
438
439 if (tenantClause) {
440 checkQuery += ` AND ${tenantClause}`;
441 checkParams.push(...tenantParams);
442 }
443
444 const check = await client.query(checkQuery, checkParams);
445 if (check.rows.length === 0) {
446 await client.query('ROLLBACK');
447 return res.status(404).json({ error: 'Purchase order not found' });
448 }
449
450 const poTenantId = check.rows[0].tenant_id;
451
452 // Delete lines first (foreign key constraint)
453 await client.query(
454 `DELETE FROM purchase_order_lines WHERE purchase_order_id = $1 AND tenant_id = $2`,
455 [id, poTenantId]
456 );
457
458 // Delete the purchase order
459 await client.query(
460 `DELETE FROM purchase_orders WHERE purchase_order_id = $1 AND tenant_id = $2`,
461 [id, poTenantId]
462 );
463
464 await client.query('COMMIT');
465 res.sendStatus(204);
466 } catch (err) {
467 await client.query('ROLLBACK');
468 console.error('Error deleting purchase order:', err);
469 res.status(500).json({ error: 'Delete failed' });
470 } finally {
471 client.release();
472 }
473});
474
475module.exports = router;