diff --git a/app/config/db.config.js b/app/config/db.config.js index 56e9813..2fd455f 100644 --- a/app/config/db.config.js +++ b/app/config/db.config.js @@ -23,5 +23,15 @@ db.Customer = require('../models/customer.model.js')(sequelize, Sequelize); db.ApiMaster = require('../models/apimaster.model.js')(sequelize, Sequelize); db.ApiKey = require('../models/apikey.model.js')(sequelize, Sequelize); db.TimeEntry = require('../models/timeentry.model.js')(sequelize, Sequelize); +db.Worker = require('../models/worker.model.js')(sequelize, Sequelize); +db.BillingType = require('../models/billingtype.model.js')(sequelize, Sequelize); +db.InventoryItem = require('../models/inventoryitem.model.js')(sequelize, Sequelize); +db.Company = require('../models/company.model.js')(sequelize, Sequelize); +db.Job = require('../models/job.model.js')(sequelize, Sequelize); +db.Invoice = require('../models/invoice.model.js')(sequelize, Sequelize); +db.CustomerPayment = require('../models/customerpayment.model.js')(sequelize, Sequelize); +db.InvoiceJob = require('../models/invoicejob.model.js')(sequelize, Sequelize); +db.ProductEntry = require('../models/productentry.model.js')(sequelize, Sequelize); +db.VersionInfo = require('../models/versioninfo.model.js')(sequelize, Sequelize); module.exports = db; diff --git a/app/config/openapi.js b/app/config/openapi.js index c381e19..e03c5a0 100644 --- a/app/config/openapi.js +++ b/app/config/openapi.js @@ -53,6 +53,124 @@ const customerSchema = { }, }; +const workerSchema = { + type: 'object', + properties: { + workerId: { type: 'integer', readOnly: true }, + workerFName: { type: 'string' }, + workerLName: { type: 'string' }, + workerTitle: { type: 'string' }, + workerDefaultBillType: { type: 'integer' }, + workerCompId: { type: 'integer' }, + workerArch: { type: 'boolean', readOnly: true }, + }, +}; + +const billingTypeSchema = { + type: 'object', + properties: { + btId: { type: 'integer', readOnly: true }, + btName: { type: 'string' }, + btHourlyRate: { type: 'number' }, + btCompId: { type: 'integer' }, + btArch: { type: 'boolean', readOnly: true }, + }, +}; + +const inventoryItemSchema = { + type: 'object', + properties: { + invitId: { type: 'integer', readOnly: true }, + invitDescription: { type: 'string' }, + invitQty: { type: 'number' }, + invitCompId: { type: 'integer' }, + invitArch: { type: 'boolean', readOnly: true }, + }, +}; + +const companySchema = { + type: 'object', + properties: { + compId: { type: 'integer', readOnly: true }, + compName: { type: 'string' }, + compAddress1: { type: 'string' }, + compAddress2: { type: 'string' }, + compCity: { type: 'string' }, + compState: { type: 'string', maxLength: 2 }, + compZip: { type: 'string' }, + compPhone: { type: 'string' }, + compEmail: { type: 'string', format: 'email' }, + compArch: { type: 'boolean', readOnly: true }, + }, +}; + +const jobSchema = { + type: 'object', + properties: { + jobId: { type: 'integer', readOnly: true }, + jobCustId: { type: 'integer' }, + jobDesc: { type: 'string' }, + jobInvoiced: { type: 'boolean' }, + jobArch: { type: 'boolean', readOnly: true }, + }, +}; + +const invoiceSchema = { + type: 'object', + properties: { + invId: { type: 'integer', readOnly: true }, + invCustId: { type: 'integer' }, + invDate: { type: 'string', format: 'date' }, + invDueDate: { type: 'string', format: 'date' }, + invPaid: { type: 'boolean' }, + invArch: { type: 'boolean', readOnly: true }, + }, +}; + +const customerPaymentSchema = { + type: 'object', + properties: { + cpayId: { type: 'integer', readOnly: true }, + cpayCustId: { type: 'integer' }, + cpayDescription: { type: 'string' }, + cpayDate: { type: 'string', format: 'date' }, + cpayAmount: { type: 'number' }, + cpayArch: { type: 'boolean', readOnly: true }, + }, +}; + +const invoiceJobSchema = { + type: 'object', + properties: { + injbId: { type: 'integer', readOnly: true }, + injbInvId: { type: 'integer' }, + injbJobId: { type: 'integer' }, + injbAmount: { type: 'number' }, + injbArch: { type: 'boolean', readOnly: true }, + }, +}; + +const productEntrySchema = { + type: 'object', + properties: { + pentId: { type: 'integer', readOnly: true }, + pentQty: { type: 'integer' }, + pentJobId: { type: 'integer' }, + pentInvtId: { type: 'integer' }, + pentTaxable: { type: 'boolean', nullable: true }, + penArch: { type: 'boolean', readOnly: true }, + }, +}; + +const versionInfoSchema = { + type: 'object', + properties: { + viId: { type: 'integer', readOnly: true }, + viVersion: { type: 'string' }, + viDate: { type: 'string', format: 'date-time' }, + }, +}; + const timeEntrySchema = { type: 'object', properties: { @@ -92,6 +210,16 @@ const spec = { schemas: { Customer: customerSchema, TimeEntry: timeEntrySchema, + Worker: workerSchema, + BillingType: billingTypeSchema, + InventoryItem: inventoryItemSchema, + Company: companySchema, + Job: jobSchema, + Invoice: invoiceSchema, + CustomerPayment: customerPaymentSchema, + InvoiceJob: invoiceJobSchema, + ProductEntry: productEntrySchema, + VersionInfo: versionInfoSchema, Error: errorResponse, }, }, @@ -219,6 +347,323 @@ const spec = { responses: { 200: { description: 'OK' }, 400: { description: 'Invalid company id' }, 403: { description: 'Auth failure' } }, }, }, + '/v1/worker': { + post: { + summary: 'Create a worker', + security: [{ authKey: [] }], + requestBody: { + required: true, + content: { + 'application/json': { schema: { $ref: '#/components/schemas/Worker' } }, + }, + }, + responses: { + 201: { description: 'Created', content: { 'application/json': { schema: { $ref: '#/components/schemas/Worker' } } } }, + 400: { description: 'Bad request' }, + 403: { description: 'Missing or invalid authKey' }, + }, + }, + }, + '/v1/worker/{id}': { + get: { + summary: 'Get one worker', + security: [{ authKey: [] }], + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }], + responses: { 200: { description: 'Found' }, 404: { description: 'Not found' }, 403: { description: 'Auth failure' } }, + }, + patch: { + summary: 'Partial update of a worker', + security: [{ authKey: [] }], + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }], + requestBody: { content: { 'application/json': { schema: { $ref: '#/components/schemas/Worker' } } } }, + responses: { 200: { description: 'Updated' }, 400: { description: 'No updatable fields supplied' }, 404: { description: 'Not found' }, 403: { description: 'Auth failure' } }, + }, + delete: { + summary: 'Soft-delete a worker', + security: [{ authKey: [] }], + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }], + responses: { 200: { description: 'Archived' }, 404: { description: 'Not found' }, 403: { description: 'Auth failure' } }, + }, + }, + '/v1/worker/bycompany/{id}': { + get: { + summary: 'List workers in a company (paginated)', + security: [{ authKey: [] }], + parameters: [ + { name: 'id', in: 'path', required: true, schema: { type: 'integer' } }, + { name: 'limit', in: 'query', schema: { type: 'integer', default: 100, maximum: 500 } }, + { name: 'offset', in: 'query', schema: { type: 'integer', default: 0 } }, + ], + responses: { 200: { description: 'OK' }, 400: { description: 'Invalid company id' }, 403: { description: 'Auth failure' } }, + }, + }, + '/v1/billingtype': { + post: { + summary: 'Create a billing type', + security: [{ authKey: [] }], + requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/BillingType' } } } }, + responses: { 201: { description: 'Created' }, 400: { description: 'Bad request' }, 403: { description: 'Auth failure' } }, + }, + }, + '/v1/billingtype/{id}': { + get: { + summary: 'Get one billing type', + security: [{ authKey: [] }], + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }], + responses: { 200: { description: 'Found' }, 404: { description: 'Not found' }, 403: { description: 'Auth failure' } }, + }, + patch: { + summary: 'Partial update of a billing type', + security: [{ authKey: [] }], + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }], + requestBody: { content: { 'application/json': { schema: { $ref: '#/components/schemas/BillingType' } } } }, + responses: { 200: { description: 'Updated' }, 400: { description: 'No updatable fields supplied' }, 404: { description: 'Not found' }, 403: { description: 'Auth failure' } }, + }, + delete: { + summary: 'Soft-delete a billing type', + security: [{ authKey: [] }], + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }], + responses: { 200: { description: 'Archived' }, 404: { description: 'Not found' }, 403: { description: 'Auth failure' } }, + }, + }, + '/v1/billingtype/bycompany/{id}': { + get: { + summary: 'List billing types in a company (paginated)', + security: [{ authKey: [] }], + parameters: [ + { name: 'id', in: 'path', required: true, schema: { type: 'integer' } }, + { name: 'limit', in: 'query', schema: { type: 'integer', default: 100, maximum: 500 } }, + { name: 'offset', in: 'query', schema: { type: 'integer', default: 0 } }, + ], + responses: { 200: { description: 'OK' }, 400: { description: 'Invalid company id' }, 403: { description: 'Auth failure' } }, + }, + }, + '/v1/inventoryitem': { + post: { + summary: 'Create an inventory item', + security: [{ authKey: [] }], + requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/InventoryItem' } } } }, + responses: { 201: { description: 'Created' }, 400: { description: 'Bad request' }, 403: { description: 'Auth failure' } }, + }, + }, + '/v1/inventoryitem/{id}': { + get: { + summary: 'Get one inventory item', + security: [{ authKey: [] }], + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }], + responses: { 200: { description: 'Found' }, 404: { description: 'Not found' }, 403: { description: 'Auth failure' } }, + }, + patch: { + summary: 'Partial update of an inventory item', + security: [{ authKey: [] }], + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }], + requestBody: { content: { 'application/json': { schema: { $ref: '#/components/schemas/InventoryItem' } } } }, + responses: { 200: { description: 'Updated' }, 400: { description: 'No updatable fields supplied' }, 404: { description: 'Not found' }, 403: { description: 'Auth failure' } }, + }, + delete: { + summary: 'Soft-delete an inventory item', + security: [{ authKey: [] }], + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }], + responses: { 200: { description: 'Archived' }, 404: { description: 'Not found' }, 403: { description: 'Auth failure' } }, + }, + }, + '/v1/inventoryitem/bycompany/{id}': { + get: { + summary: 'List inventory items in a company (paginated)', + security: [{ authKey: [] }], + parameters: [ + { name: 'id', in: 'path', required: true, schema: { type: 'integer' } }, + { name: 'limit', in: 'query', schema: { type: 'integer', default: 100, maximum: 500 } }, + { name: 'offset', in: 'query', schema: { type: 'integer', default: 0 } }, + ], + responses: { 200: { description: 'OK' }, 400: { description: 'Invalid company id' }, 403: { description: 'Auth failure' } }, + }, + }, + '/v1/company': { + post: { + summary: 'Create a company (master keys only)', + security: [{ authKey: [] }], + requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/Company' } } } }, + responses: { 201: { description: 'Created' }, 400: { description: 'Bad request' }, 403: { description: 'Non-master key' } }, + }, + get: { + summary: 'List all companies (master keys only, paginated)', + security: [{ authKey: [] }], + parameters: [ + { name: 'limit', in: 'query', schema: { type: 'integer', default: 100, maximum: 500 } }, + { name: 'offset', in: 'query', schema: { type: 'integer', default: 0 } }, + ], + responses: { 200: { description: 'OK' }, 403: { description: 'Non-master key' } }, + }, + }, + '/v1/company/{id}': { + get: { + summary: 'Get one company (master: any; non-master: own only)', + security: [{ authKey: [] }], + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }], + responses: { 200: { description: 'Found' }, 404: { description: 'Not found' }, 403: { description: 'Auth failure' } }, + }, + patch: { + summary: 'Partial update of a company (master: any; non-master: own only)', + security: [{ authKey: [] }], + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }], + requestBody: { content: { 'application/json': { schema: { $ref: '#/components/schemas/Company' } } } }, + responses: { 200: { description: 'Updated' }, 400: { description: 'No updatable fields supplied' }, 404: { description: 'Not found' }, 403: { description: 'Auth failure' } }, + }, + delete: { + summary: 'Soft-delete a company (master keys only)', + security: [{ authKey: [] }], + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }], + responses: { 200: { description: 'Archived' }, 404: { description: 'Not found' }, 403: { description: 'Non-master key' } }, + }, + }, + '/v1/job': { + post: { + summary: 'Create a job', + security: [{ authKey: [] }], + requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/Job' } } } }, + responses: { 201: { description: 'Created' }, 400: { description: 'Bad request' }, 403: { description: 'Auth failure' } }, + }, + }, + '/v1/job/{id}': { + get: { summary: 'Get one job', security: [{ authKey: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }], responses: { 200: { description: 'Found' }, 404: { description: 'Not found' }, 403: { description: 'Auth failure' } } }, + patch: { summary: 'Partial update of a job', security: [{ authKey: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }], requestBody: { content: { 'application/json': { schema: { $ref: '#/components/schemas/Job' } } } }, responses: { 200: { description: 'Updated' }, 400: { description: 'No updatable fields supplied' }, 404: { description: 'Not found' }, 403: { description: 'Auth failure' } } }, + delete: { summary: 'Soft-delete a job', security: [{ authKey: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }], responses: { 200: { description: 'Archived' }, 404: { description: 'Not found' }, 403: { description: 'Auth failure' } } }, + }, + '/v1/job/bycustomer/{id}': { + get: { + summary: 'List jobs for a customer (paginated)', + security: [{ authKey: [] }], + parameters: [ + { name: 'id', in: 'path', required: true, schema: { type: 'integer' } }, + { name: 'limit', in: 'query', schema: { type: 'integer', default: 100, maximum: 500 } }, + { name: 'offset', in: 'query', schema: { type: 'integer', default: 0 } }, + ], + responses: { 200: { description: 'OK' }, 400: { description: 'Invalid customer id' }, 403: { description: 'Auth failure' } }, + }, + }, + '/v1/invoice': { + post: { + summary: 'Create an invoice', + security: [{ authKey: [] }], + requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/Invoice' } } } }, + responses: { 201: { description: 'Created' }, 400: { description: 'Bad request' }, 403: { description: 'Auth failure' } }, + }, + }, + '/v1/invoice/{id}': { + get: { summary: 'Get one invoice', security: [{ authKey: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }], responses: { 200: { description: 'Found' }, 404: { description: 'Not found' }, 403: { description: 'Auth failure' } } }, + patch: { summary: 'Partial update of an invoice', security: [{ authKey: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }], requestBody: { content: { 'application/json': { schema: { $ref: '#/components/schemas/Invoice' } } } }, responses: { 200: { description: 'Updated' }, 400: { description: 'No updatable fields supplied' }, 404: { description: 'Not found' }, 403: { description: 'Auth failure' } } }, + delete: { summary: 'Soft-delete an invoice', security: [{ authKey: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }], responses: { 200: { description: 'Archived' }, 404: { description: 'Not found' }, 403: { description: 'Auth failure' } } }, + }, + '/v1/invoice/bycustomer/{id}': { + get: { + summary: 'List invoices for a customer (paginated)', + security: [{ authKey: [] }], + parameters: [ + { name: 'id', in: 'path', required: true, schema: { type: 'integer' } }, + { name: 'limit', in: 'query', schema: { type: 'integer', default: 100, maximum: 500 } }, + { name: 'offset', in: 'query', schema: { type: 'integer', default: 0 } }, + ], + responses: { 200: { description: 'OK' }, 400: { description: 'Invalid customer id' }, 403: { description: 'Auth failure' } }, + }, + }, + '/v1/customerpayment': { + post: { + summary: 'Create a customer payment', + security: [{ authKey: [] }], + requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/CustomerPayment' } } } }, + responses: { 201: { description: 'Created' }, 400: { description: 'Bad request' }, 403: { description: 'Auth failure' } }, + }, + }, + '/v1/customerpayment/{id}': { + get: { summary: 'Get one customer payment', security: [{ authKey: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }], responses: { 200: { description: 'Found' }, 404: { description: 'Not found' }, 403: { description: 'Auth failure' } } }, + patch: { summary: 'Partial update of a customer payment', security: [{ authKey: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }], requestBody: { content: { 'application/json': { schema: { $ref: '#/components/schemas/CustomerPayment' } } } }, responses: { 200: { description: 'Updated' }, 400: { description: 'No updatable fields supplied' }, 404: { description: 'Not found' }, 403: { description: 'Auth failure' } } }, + delete: { summary: 'Soft-delete a customer payment', security: [{ authKey: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }], responses: { 200: { description: 'Archived' }, 404: { description: 'Not found' }, 403: { description: 'Auth failure' } } }, + }, + '/v1/customerpayment/bycustomer/{id}': { + get: { + summary: 'List customer payments for a customer (paginated, newest first)', + security: [{ authKey: [] }], + parameters: [ + { name: 'id', in: 'path', required: true, schema: { type: 'integer' } }, + { name: 'limit', in: 'query', schema: { type: 'integer', default: 100, maximum: 500 } }, + { name: 'offset', in: 'query', schema: { type: 'integer', default: 0 } }, + ], + responses: { 200: { description: 'OK' }, 400: { description: 'Invalid customer id' }, 403: { description: 'Auth failure' } }, + }, + }, + '/v1/invoicejob': { + post: { + summary: 'Create an invoice line (job → invoice)', + security: [{ authKey: [] }], + requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/InvoiceJob' } } } }, + responses: { 201: { description: 'Created' }, 400: { description: 'Bad request' }, 403: { description: 'Auth failure' } }, + }, + }, + '/v1/invoicejob/{id}': { + get: { summary: 'Get one invoice line', security: [{ authKey: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }], responses: { 200: { description: 'Found' }, 404: { description: 'Not found' }, 403: { description: 'Auth failure' } } }, + patch: { summary: 'Partial update of an invoice line', security: [{ authKey: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }], requestBody: { content: { 'application/json': { schema: { $ref: '#/components/schemas/InvoiceJob' } } } }, responses: { 200: { description: 'Updated' }, 400: { description: 'No updatable fields supplied' }, 404: { description: 'Not found' }, 403: { description: 'Auth failure' } } }, + delete: { summary: 'Soft-delete an invoice line', security: [{ authKey: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }], responses: { 200: { description: 'Archived' }, 404: { description: 'Not found' }, 403: { description: 'Auth failure' } } }, + }, + '/v1/invoicejob/byinvoice/{id}': { + get: { + summary: 'List invoice lines for an invoice (paginated)', + security: [{ authKey: [] }], + parameters: [ + { name: 'id', in: 'path', required: true, schema: { type: 'integer' } }, + { name: 'limit', in: 'query', schema: { type: 'integer', default: 100, maximum: 500 } }, + { name: 'offset', in: 'query', schema: { type: 'integer', default: 0 } }, + ], + responses: { 200: { description: 'OK' }, 400: { description: 'Invalid invoice id' }, 403: { description: 'Auth failure' } }, + }, + }, + '/v1/productentry': { + post: { + summary: 'Create a product entry', + security: [{ authKey: [] }], + requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/ProductEntry' } } } }, + responses: { 201: { description: 'Created' }, 400: { description: 'Bad request' }, 403: { description: 'Auth failure' } }, + }, + }, + '/v1/productentry/{id}': { + get: { summary: 'Get one product entry', security: [{ authKey: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }], responses: { 200: { description: 'Found' }, 404: { description: 'Not found' }, 403: { description: 'Auth failure' } } }, + patch: { summary: 'Partial update of a product entry', security: [{ authKey: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }], requestBody: { content: { 'application/json': { schema: { $ref: '#/components/schemas/ProductEntry' } } } }, responses: { 200: { description: 'Updated' }, 400: { description: 'No updatable fields supplied' }, 404: { description: 'Not found' }, 403: { description: 'Auth failure' } } }, + delete: { summary: 'Soft-delete a product entry', security: [{ authKey: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }], responses: { 200: { description: 'Archived' }, 404: { description: 'Not found' }, 403: { description: 'Auth failure' } } }, + }, + '/v1/productentry/byjob/{id}': { + get: { + summary: 'List product entries for a job (paginated)', + security: [{ authKey: [] }], + parameters: [ + { name: 'id', in: 'path', required: true, schema: { type: 'integer' } }, + { name: 'limit', in: 'query', schema: { type: 'integer', default: 100, maximum: 500 } }, + { name: 'offset', in: 'query', schema: { type: 'integer', default: 0 } }, + ], + responses: { 200: { description: 'OK' }, 400: { description: 'Invalid job id' }, 403: { description: 'Auth failure' } }, + }, + }, + '/v1/versioninfo': { + post: { + summary: 'Create a version info record (master keys only)', + security: [{ authKey: [] }], + requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/VersionInfo' } } } }, + responses: { 201: { description: 'Created' }, 400: { description: 'Bad request' }, 403: { description: 'Non-master key' } }, + }, + get: { + summary: 'List version info (any authKey)', + security: [{ authKey: [] }], + parameters: [ + { name: 'limit', in: 'query', schema: { type: 'integer', default: 100, maximum: 500 } }, + { name: 'offset', in: 'query', schema: { type: 'integer', default: 0 } }, + ], + responses: { 200: { description: 'OK' }, 403: { description: 'Missing authKey' } }, + }, + }, + '/v1/versioninfo/{id}': { + get: { summary: 'Get one version info (any authKey)', security: [{ authKey: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }], responses: { 200: { description: 'Found' }, 404: { description: 'Not found' }, 403: { description: 'Missing authKey' } } }, + 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' } } }, + 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' } } }, + }, }, }; diff --git a/app/controllers/billingtypecontroller.js b/app/controllers/billingtypecontroller.js new file mode 100644 index 0000000..f122e2d --- /dev/null +++ b/app/controllers/billingtypecontroller.js @@ -0,0 +1,224 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark +"use strict"; + +const db = require('../config/db.config.js'); +const log = require('../config/logger.js'); +const auth = require('../middleware/auth.js'); +const BillingType = db.BillingType; + +const IsMaster = auth.isMaster; +const GetCompanyId = auth.getCompanyId; + +const ALLOWED_FIELDS_CREATE = ['btName', 'btHourlyRate', 'btCompId']; +const ALLOWED_FIELDS_UPDATE = ['btName', 'btHourlyRate']; + +exports.create = async (req, res) => { + const authKey = req.get('authKey'); + if (!authKey) { + return res.status(403).json({ message: "Authorization key not sent." }); + } + + let isAuthKeyMasterKey; + try { + isAuthKeyMasterKey = await IsMaster(authKey); + } catch (error) { + log.error({ err: error }, 'IsMaster failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } + + const body = req.body || {}; + const payload = {}; + for (const f of ALLOWED_FIELDS_CREATE) { + if (body[f] !== undefined) payload[f] = body[f]; + } + + if (!isAuthKeyMasterKey) { + let authKeyCompanyId; + try { + authKeyCompanyId = await GetCompanyId(authKey); + } catch (error) { + log.error({ err: error }, 'GetCompanyId failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } + if (authKeyCompanyId === -1) { + return res.status(403).json({ message: "Invalid Authorization Key." }); + } + if (payload.btCompId !== undefined && Number(payload.btCompId) !== authKeyCompanyId) { + return res.status(403).json({ + message: "Cannot create a billing type for a company you do not belong to.", + }); + } + payload.btCompId = authKeyCompanyId; + } else { + if (payload.btCompId === undefined || Number(payload.btCompId) <= 0) { + return res.status(400).json({ + message: "Master-key requests must specify btCompId.", + }); + } + } + + payload.btArch = false; + + try { + const created = await BillingType.create(payload); + return res.status(201).json({ message: "Billing type created.", billingType: created }); + } catch (error) { + log.error({ err: error }, 'BillingType.create failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } +}; + +exports.getById = async (req, res) => { + const authKey = req.get('authKey'); + if (!authKey) { + return res.status(403).json({ message: "Authorization key not sent." }); + } + + let billingType; + try { + billingType = await BillingType.findByPk(req.params.id); + } catch (error) { + log.error({ err: error }, 'BillingType.findByPk failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } + if (!billingType || billingType.btArch) { + return res.status(404).json({ message: "Not found." }); + } + + const isMaster = await IsMaster(authKey); + if (!isMaster) { + const companyId = await GetCompanyId(authKey); + if (companyId === -1 || billingType.btCompId !== companyId) { + return res.status(403).json({ message: "Invalid Authorization Key." }); + } + } + return res.status(200).json({ message: "Found.", billingType }); +}; + +exports.listByCompany = async (req, res) => { + const authKey = req.get('authKey'); + if (!authKey) { + return res.status(403).json({ message: "Authorization key not sent." }); + } + + const targetCompanyId = Number(req.params.id); + if (!Number.isInteger(targetCompanyId) || targetCompanyId <= 0) { + return res.status(400).json({ message: "Invalid company id." }); + } + + const isMaster = await IsMaster(authKey); + if (!isMaster) { + const companyId = await GetCompanyId(authKey); + if (companyId === -1 || companyId !== targetCompanyId) { + return res.status(403).json({ message: "Invalid Authorization Key." }); + } + } + + const requestedLimit = parseInt(req.query.limit, 10); + const limit = Number.isInteger(requestedLimit) && requestedLimit > 0 + ? Math.min(requestedLimit, 500) + : 100; + const requestedOffset = parseInt(req.query.offset, 10); + const offset = Number.isInteger(requestedOffset) && requestedOffset >= 0 + ? requestedOffset + : 0; + + try { + const { count, rows } = await BillingType.findAndCountAll({ + where: { btCompId: targetCompanyId, btArch: false }, + limit, + offset, + order: [['btId', 'ASC']], + }); + return res.status(200).json({ + message: "Successfully retrieved billing types with CompanyId " + targetCompanyId, + count, + limit, + offset, + billingTypes: rows, + }); + } catch (error) { + log.error({ err: error }, 'BillingType.findAndCountAll failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } +}; + +exports.update = async (req, res) => { + const authKey = req.get('authKey'); + if (!authKey) { + return res.status(403).json({ message: "Authorization key not sent." }); + } + + let billingType; + try { + billingType = await BillingType.findByPk(req.params.id); + } catch (error) { + log.error({ err: error }, 'BillingType.findByPk failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } + if (!billingType || billingType.btArch) { + return res.status(404).json({ message: "Not found." }); + } + + const isMaster = await IsMaster(authKey); + if (!isMaster) { + const companyId = await GetCompanyId(authKey); + if (companyId === -1 || billingType.btCompId !== companyId) { + return res.status(403).json({ message: "Invalid Authorization Key." }); + } + } + + const body = req.body || {}; + const updates = {}; + for (const f of ALLOWED_FIELDS_UPDATE) { + if (body[f] !== undefined) updates[f] = body[f]; + } + if (Object.keys(updates).length === 0) { + return res.status(400).json({ message: "No updatable fields supplied." }); + } + + try { + await billingType.update(updates); + return res.status(200).json({ message: "Updated.", billingType }); + } catch (error) { + log.error({ err: error }, 'BillingType.update failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } +}; + +exports.remove = async (req, res) => { + const authKey = req.get('authKey'); + if (!authKey) { + return res.status(403).json({ message: "Authorization key not sent." }); + } + + let billingType; + try { + billingType = await BillingType.findByPk(req.params.id); + } catch (error) { + log.error({ err: error }, 'BillingType.findByPk failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } + if (!billingType || billingType.btArch) { + return res.status(404).json({ message: "Not found." }); + } + + const isMaster = await IsMaster(authKey); + if (!isMaster) { + const companyId = await GetCompanyId(authKey); + if (companyId === -1 || billingType.btCompId !== companyId) { + return res.status(403).json({ message: "Invalid Authorization Key." }); + } + } + + try { + await billingType.update({ btArch: true }); + return res.status(200).json({ message: "Archived.", id: billingType.btId }); + } catch (error) { + log.error({ err: error }, 'BillingType archive failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } +}; + +exports._internals = { IsMaster, GetCompanyId }; diff --git a/app/controllers/companycontroller.js b/app/controllers/companycontroller.js new file mode 100644 index 0000000..2d6623f --- /dev/null +++ b/app/controllers/companycontroller.js @@ -0,0 +1,196 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark +"use strict"; + +/** + * Company controller. + * + * Company is special: compId IS the company id (no compCompId column). + * Auth shape differs from Customer/Worker: + * - POST /v1/company — master only + * - GET /v1/company — master only (list all) + * - GET /v1/company/:id — master: any; non-master: only own + * - PATCH /v1/company/:id — master: any; non-master: only own + * - DELETE /v1/company/:id — master only (soft-delete) + */ + +const db = require('../config/db.config.js'); +const log = require('../config/logger.js'); +const auth = require('../middleware/auth.js'); +const Company = db.Company; + +const IsMaster = auth.isMaster; +const GetCompanyId = auth.getCompanyId; + +const ALLOWED_FIELDS_CREATE = [ + 'compName', 'compAddress1', 'compAddress2', 'compCity', + 'compState', 'compZip', 'compPhone', 'compEmail', +]; +const ALLOWED_FIELDS_UPDATE = ALLOWED_FIELDS_CREATE; + +exports.create = async (req, res) => { + const authKey = req.get('authKey'); + if (!authKey) { + return res.status(403).json({ message: "Authorization key not sent." }); + } + const isMaster = await IsMaster(authKey); + if (!isMaster) { + return res.status(403).json({ message: "Only master keys may create companies." }); + } + + const body = req.body || {}; + const payload = {}; + for (const f of ALLOWED_FIELDS_CREATE) { + if (body[f] !== undefined) payload[f] = body[f]; + } + payload.compArch = false; + + try { + const created = await Company.create(payload); + return res.status(201).json({ message: "Company created.", company: created }); + } catch (error) { + log.error({ err: error }, 'Company.create failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } +}; + +exports.getById = async (req, res) => { + const authKey = req.get('authKey'); + if (!authKey) { + return res.status(403).json({ message: "Authorization key not sent." }); + } + + let company; + try { + company = await Company.findByPk(req.params.id); + } catch (error) { + log.error({ err: error }, 'Company.findByPk failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } + if (!company || company.compArch) { + return res.status(404).json({ message: "Not found." }); + } + + const isMaster = await IsMaster(authKey); + if (!isMaster) { + const companyId = await GetCompanyId(authKey); + if (companyId === -1 || company.compId !== companyId) { + return res.status(403).json({ message: "Invalid Authorization Key." }); + } + } + return res.status(200).json({ message: "Found.", company }); +}; + +exports.list = async (req, res) => { + const authKey = req.get('authKey'); + if (!authKey) { + return res.status(403).json({ message: "Authorization key not sent." }); + } + const isMaster = await IsMaster(authKey); + if (!isMaster) { + return res.status(403).json({ message: "Only master keys may list all companies." }); + } + + const requestedLimit = parseInt(req.query.limit, 10); + const limit = Number.isInteger(requestedLimit) && requestedLimit > 0 + ? Math.min(requestedLimit, 500) + : 100; + const requestedOffset = parseInt(req.query.offset, 10); + const offset = Number.isInteger(requestedOffset) && requestedOffset >= 0 + ? requestedOffset + : 0; + + try { + const { count, rows } = await Company.findAndCountAll({ + where: { compArch: false }, + limit, + offset, + order: [['compId', 'ASC']], + }); + return res.status(200).json({ + message: "Successfully retrieved companies", + count, + limit, + offset, + companies: rows, + }); + } catch (error) { + log.error({ err: error }, 'Company.findAndCountAll failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } +}; + +exports.update = async (req, res) => { + const authKey = req.get('authKey'); + if (!authKey) { + return res.status(403).json({ message: "Authorization key not sent." }); + } + + let company; + try { + company = await Company.findByPk(req.params.id); + } catch (error) { + log.error({ err: error }, 'Company.findByPk failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } + if (!company || company.compArch) { + return res.status(404).json({ message: "Not found." }); + } + + const isMaster = await IsMaster(authKey); + if (!isMaster) { + const companyId = await GetCompanyId(authKey); + if (companyId === -1 || company.compId !== companyId) { + return res.status(403).json({ message: "Invalid Authorization Key." }); + } + } + + const body = req.body || {}; + const updates = {}; + for (const f of ALLOWED_FIELDS_UPDATE) { + if (body[f] !== undefined) updates[f] = body[f]; + } + if (Object.keys(updates).length === 0) { + return res.status(400).json({ message: "No updatable fields supplied." }); + } + + try { + await company.update(updates); + return res.status(200).json({ message: "Updated.", company }); + } catch (error) { + log.error({ err: error }, 'Company.update failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } +}; + +exports.remove = async (req, res) => { + const authKey = req.get('authKey'); + if (!authKey) { + return res.status(403).json({ message: "Authorization key not sent." }); + } + const isMaster = await IsMaster(authKey); + if (!isMaster) { + return res.status(403).json({ message: "Only master keys may archive companies." }); + } + + let company; + try { + company = await Company.findByPk(req.params.id); + } catch (error) { + log.error({ err: error }, 'Company.findByPk failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } + if (!company || company.compArch) { + return res.status(404).json({ message: "Not found." }); + } + + try { + await company.update({ compArch: true }); + return res.status(200).json({ message: "Archived.", id: company.compId }); + } catch (error) { + log.error({ err: error }, 'Company archive failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } +}; + +exports._internals = { IsMaster, GetCompanyId }; diff --git a/app/controllers/customerpaymentcontroller.js b/app/controllers/customerpaymentcontroller.js new file mode 100644 index 0000000..69e0581 --- /dev/null +++ b/app/controllers/customerpaymentcontroller.js @@ -0,0 +1,209 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark +"use strict"; + +const db = require('../config/db.config.js'); +const log = require('../config/logger.js'); +const auth = require('../middleware/auth.js'); +const CustomerPayment = db.CustomerPayment; + +const IsMaster = auth.isMaster; +const GetCompanyId = auth.getCompanyId; +const GetCompanyIdByCustomerId = auth.getCompanyIdByCustomerId; + +const ALLOWED_FIELDS_CREATE = ['cpayCustId', 'cpayDescription', 'cpayDate', 'cpayAmount']; +const ALLOWED_FIELDS_UPDATE = ['cpayDescription', 'cpayDate', 'cpayAmount']; + +exports.create = async (req, res) => { + const authKey = req.get('authKey'); + if (!authKey) { + return res.status(403).json({ message: "Authorization key not sent." }); + } + + const body = req.body || {}; + const payload = {}; + for (const f of ALLOWED_FIELDS_CREATE) { + if (body[f] !== undefined) payload[f] = body[f]; + } + if (!payload.cpayCustId) { + return res.status(400).json({ message: "cpayCustId is required." }); + } + + const isMaster = await IsMaster(authKey); + if (!isMaster) { + const authCompanyId = await GetCompanyId(authKey); + if (authCompanyId === -1) { + return res.status(403).json({ message: "Invalid Authorization Key." }); + } + const custCompanyId = await GetCompanyIdByCustomerId(payload.cpayCustId); + if (custCompanyId === -1 || custCompanyId !== authCompanyId) { + return res.status(403).json({ + message: "Cannot create a payment for a customer in a company you do not belong to.", + }); + } + } + + payload.cpayArch = false; + + try { + const created = await CustomerPayment.create(payload); + return res.status(201).json({ message: "Customer payment created.", customerPayment: created }); + } catch (error) { + log.error({ err: error }, 'CustomerPayment.create failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } +}; + +exports.getById = async (req, res) => { + const authKey = req.get('authKey'); + if (!authKey) { + return res.status(403).json({ message: "Authorization key not sent." }); + } + + let payment; + try { + payment = await CustomerPayment.findByPk(req.params.id); + } catch (error) { + log.error({ err: error }, 'CustomerPayment.findByPk failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } + if (!payment || payment.cpayArch) { + return res.status(404).json({ message: "Not found." }); + } + + const isMaster = await IsMaster(authKey); + if (!isMaster) { + const authCompanyId = await GetCompanyId(authKey); + const cpCompanyId = await GetCompanyIdByCustomerId(payment.cpayCustId); + if (authCompanyId === -1 || cpCompanyId === -1 || authCompanyId !== cpCompanyId) { + return res.status(403).json({ message: "Invalid Authorization Key." }); + } + } + return res.status(200).json({ message: "Found.", customerPayment: payment }); +}; + +exports.listByCustomer = async (req, res) => { + const authKey = req.get('authKey'); + if (!authKey) { + return res.status(403).json({ message: "Authorization key not sent." }); + } + + const targetCustomerId = Number(req.params.id); + if (!Number.isInteger(targetCustomerId) || targetCustomerId <= 0) { + return res.status(400).json({ message: "Invalid customer id." }); + } + + const isMaster = await IsMaster(authKey); + if (!isMaster) { + const authCompanyId = await GetCompanyId(authKey); + const custCompanyId = await GetCompanyIdByCustomerId(targetCustomerId); + if (authCompanyId === -1 || custCompanyId === -1 || authCompanyId !== custCompanyId) { + return res.status(403).json({ message: "Invalid Authorization Key." }); + } + } + + const requestedLimit = parseInt(req.query.limit, 10); + const limit = Number.isInteger(requestedLimit) && requestedLimit > 0 + ? Math.min(requestedLimit, 500) + : 100; + const requestedOffset = parseInt(req.query.offset, 10); + const offset = Number.isInteger(requestedOffset) && requestedOffset >= 0 + ? requestedOffset + : 0; + + try { + const { count, rows } = await CustomerPayment.findAndCountAll({ + where: { cpayCustId: targetCustomerId, cpayArch: false }, + limit, offset, + order: [['cpayDate', 'DESC']], + }); + return res.status(200).json({ + message: "Successfully retrieved customer payments for CustomerId " + targetCustomerId, + count, limit, offset, customerPayments: rows, + }); + } catch (error) { + log.error({ err: error }, 'CustomerPayment.findAndCountAll failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } +}; + +exports.update = async (req, res) => { + const authKey = req.get('authKey'); + if (!authKey) { + return res.status(403).json({ message: "Authorization key not sent." }); + } + + let payment; + try { + payment = await CustomerPayment.findByPk(req.params.id); + } catch (error) { + log.error({ err: error }, 'CustomerPayment.findByPk failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } + if (!payment || payment.cpayArch) { + return res.status(404).json({ message: "Not found." }); + } + + const isMaster = await IsMaster(authKey); + if (!isMaster) { + const authCompanyId = await GetCompanyId(authKey); + const cpCompanyId = await GetCompanyIdByCustomerId(payment.cpayCustId); + if (authCompanyId === -1 || cpCompanyId === -1 || authCompanyId !== cpCompanyId) { + return res.status(403).json({ message: "Invalid Authorization Key." }); + } + } + + const body = req.body || {}; + const updates = {}; + for (const f of ALLOWED_FIELDS_UPDATE) { + if (body[f] !== undefined) updates[f] = body[f]; + } + if (Object.keys(updates).length === 0) { + return res.status(400).json({ message: "No updatable fields supplied." }); + } + + try { + await payment.update(updates); + return res.status(200).json({ message: "Updated.", customerPayment: payment }); + } catch (error) { + log.error({ err: error }, 'CustomerPayment.update failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } +}; + +exports.remove = async (req, res) => { + const authKey = req.get('authKey'); + if (!authKey) { + return res.status(403).json({ message: "Authorization key not sent." }); + } + + let payment; + try { + payment = await CustomerPayment.findByPk(req.params.id); + } catch (error) { + log.error({ err: error }, 'CustomerPayment.findByPk failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } + if (!payment || payment.cpayArch) { + return res.status(404).json({ message: "Not found." }); + } + + const isMaster = await IsMaster(authKey); + if (!isMaster) { + const authCompanyId = await GetCompanyId(authKey); + const cpCompanyId = await GetCompanyIdByCustomerId(payment.cpayCustId); + if (authCompanyId === -1 || cpCompanyId === -1 || authCompanyId !== cpCompanyId) { + return res.status(403).json({ message: "Invalid Authorization Key." }); + } + } + + try { + await payment.update({ cpayArch: true }); + return res.status(200).json({ message: "Archived.", id: payment.cpayId }); + } catch (error) { + log.error({ err: error }, 'CustomerPayment archive failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } +}; + +exports._internals = { IsMaster, GetCompanyId, GetCompanyIdByCustomerId }; diff --git a/app/controllers/inventoryitemcontroller.js b/app/controllers/inventoryitemcontroller.js new file mode 100644 index 0000000..4ac0c2b --- /dev/null +++ b/app/controllers/inventoryitemcontroller.js @@ -0,0 +1,224 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark +"use strict"; + +const db = require('../config/db.config.js'); +const log = require('../config/logger.js'); +const auth = require('../middleware/auth.js'); +const InventoryItem = db.InventoryItem; + +const IsMaster = auth.isMaster; +const GetCompanyId = auth.getCompanyId; + +const ALLOWED_FIELDS_CREATE = ['invitDescription', 'invitQty', 'invitCompId']; +const ALLOWED_FIELDS_UPDATE = ['invitDescription', 'invitQty']; + +exports.create = async (req, res) => { + const authKey = req.get('authKey'); + if (!authKey) { + return res.status(403).json({ message: "Authorization key not sent." }); + } + + let isAuthKeyMasterKey; + try { + isAuthKeyMasterKey = await IsMaster(authKey); + } catch (error) { + log.error({ err: error }, 'IsMaster failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } + + const body = req.body || {}; + const payload = {}; + for (const f of ALLOWED_FIELDS_CREATE) { + if (body[f] !== undefined) payload[f] = body[f]; + } + + if (!isAuthKeyMasterKey) { + let authKeyCompanyId; + try { + authKeyCompanyId = await GetCompanyId(authKey); + } catch (error) { + log.error({ err: error }, 'GetCompanyId failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } + if (authKeyCompanyId === -1) { + return res.status(403).json({ message: "Invalid Authorization Key." }); + } + if (payload.invitCompId !== undefined && Number(payload.invitCompId) !== authKeyCompanyId) { + return res.status(403).json({ + message: "Cannot create an inventory item for a company you do not belong to.", + }); + } + payload.invitCompId = authKeyCompanyId; + } else { + if (payload.invitCompId === undefined || Number(payload.invitCompId) <= 0) { + return res.status(400).json({ + message: "Master-key requests must specify invitCompId.", + }); + } + } + + payload.invitArch = false; + + try { + const created = await InventoryItem.create(payload); + return res.status(201).json({ message: "Inventory item created.", inventoryItem: created }); + } catch (error) { + log.error({ err: error }, 'InventoryItem.create failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } +}; + +exports.getById = async (req, res) => { + const authKey = req.get('authKey'); + if (!authKey) { + return res.status(403).json({ message: "Authorization key not sent." }); + } + + let inventoryItem; + try { + inventoryItem = await InventoryItem.findByPk(req.params.id); + } catch (error) { + log.error({ err: error }, 'InventoryItem.findByPk failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } + if (!inventoryItem || inventoryItem.invitArch) { + return res.status(404).json({ message: "Not found." }); + } + + const isMaster = await IsMaster(authKey); + if (!isMaster) { + const companyId = await GetCompanyId(authKey); + if (companyId === -1 || inventoryItem.invitCompId !== companyId) { + return res.status(403).json({ message: "Invalid Authorization Key." }); + } + } + return res.status(200).json({ message: "Found.", inventoryItem }); +}; + +exports.listByCompany = async (req, res) => { + const authKey = req.get('authKey'); + if (!authKey) { + return res.status(403).json({ message: "Authorization key not sent." }); + } + + const targetCompanyId = Number(req.params.id); + if (!Number.isInteger(targetCompanyId) || targetCompanyId <= 0) { + return res.status(400).json({ message: "Invalid company id." }); + } + + const isMaster = await IsMaster(authKey); + if (!isMaster) { + const companyId = await GetCompanyId(authKey); + if (companyId === -1 || companyId !== targetCompanyId) { + return res.status(403).json({ message: "Invalid Authorization Key." }); + } + } + + const requestedLimit = parseInt(req.query.limit, 10); + const limit = Number.isInteger(requestedLimit) && requestedLimit > 0 + ? Math.min(requestedLimit, 500) + : 100; + const requestedOffset = parseInt(req.query.offset, 10); + const offset = Number.isInteger(requestedOffset) && requestedOffset >= 0 + ? requestedOffset + : 0; + + try { + const { count, rows } = await InventoryItem.findAndCountAll({ + where: { invitCompId: targetCompanyId, invitArch: false }, + limit, + offset, + order: [['invitId', 'ASC']], + }); + return res.status(200).json({ + message: "Successfully retrieved inventory items with CompanyId " + targetCompanyId, + count, + limit, + offset, + inventoryItems: rows, + }); + } catch (error) { + log.error({ err: error }, 'InventoryItem.findAndCountAll failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } +}; + +exports.update = async (req, res) => { + const authKey = req.get('authKey'); + if (!authKey) { + return res.status(403).json({ message: "Authorization key not sent." }); + } + + let inventoryItem; + try { + inventoryItem = await InventoryItem.findByPk(req.params.id); + } catch (error) { + log.error({ err: error }, 'InventoryItem.findByPk failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } + if (!inventoryItem || inventoryItem.invitArch) { + return res.status(404).json({ message: "Not found." }); + } + + const isMaster = await IsMaster(authKey); + if (!isMaster) { + const companyId = await GetCompanyId(authKey); + if (companyId === -1 || inventoryItem.invitCompId !== companyId) { + return res.status(403).json({ message: "Invalid Authorization Key." }); + } + } + + const body = req.body || {}; + const updates = {}; + for (const f of ALLOWED_FIELDS_UPDATE) { + if (body[f] !== undefined) updates[f] = body[f]; + } + if (Object.keys(updates).length === 0) { + return res.status(400).json({ message: "No updatable fields supplied." }); + } + + try { + await inventoryItem.update(updates); + return res.status(200).json({ message: "Updated.", inventoryItem }); + } catch (error) { + log.error({ err: error }, 'InventoryItem.update failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } +}; + +exports.remove = async (req, res) => { + const authKey = req.get('authKey'); + if (!authKey) { + return res.status(403).json({ message: "Authorization key not sent." }); + } + + let inventoryItem; + try { + inventoryItem = await InventoryItem.findByPk(req.params.id); + } catch (error) { + log.error({ err: error }, 'InventoryItem.findByPk failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } + if (!inventoryItem || inventoryItem.invitArch) { + return res.status(404).json({ message: "Not found." }); + } + + const isMaster = await IsMaster(authKey); + if (!isMaster) { + const companyId = await GetCompanyId(authKey); + if (companyId === -1 || inventoryItem.invitCompId !== companyId) { + return res.status(403).json({ message: "Invalid Authorization Key." }); + } + } + + try { + await inventoryItem.update({ invitArch: true }); + return res.status(200).json({ message: "Archived.", id: inventoryItem.invitId }); + } catch (error) { + log.error({ err: error }, 'InventoryItem archive failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } +}; + +exports._internals = { IsMaster, GetCompanyId }; diff --git a/app/controllers/invoicecontroller.js b/app/controllers/invoicecontroller.js new file mode 100644 index 0000000..f1e1b3d --- /dev/null +++ b/app/controllers/invoicecontroller.js @@ -0,0 +1,210 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark +"use strict"; + +const db = require('../config/db.config.js'); +const log = require('../config/logger.js'); +const auth = require('../middleware/auth.js'); +const Invoice = db.Invoice; + +const IsMaster = auth.isMaster; +const GetCompanyId = auth.getCompanyId; +const GetCompanyIdByCustomerId = auth.getCompanyIdByCustomerId; + +const ALLOWED_FIELDS_CREATE = ['invCustId', 'invDate', 'invDueDate', 'invPaid']; +const ALLOWED_FIELDS_UPDATE = ['invDate', 'invDueDate', 'invPaid']; + +exports.create = async (req, res) => { + const authKey = req.get('authKey'); + if (!authKey) { + return res.status(403).json({ message: "Authorization key not sent." }); + } + + const body = req.body || {}; + const payload = {}; + for (const f of ALLOWED_FIELDS_CREATE) { + if (body[f] !== undefined) payload[f] = body[f]; + } + if (!payload.invCustId) { + return res.status(400).json({ message: "invCustId is required." }); + } + + const isMaster = await IsMaster(authKey); + if (!isMaster) { + const authCompanyId = await GetCompanyId(authKey); + if (authCompanyId === -1) { + return res.status(403).json({ message: "Invalid Authorization Key." }); + } + const custCompanyId = await GetCompanyIdByCustomerId(payload.invCustId); + if (custCompanyId === -1 || custCompanyId !== authCompanyId) { + return res.status(403).json({ + message: "Cannot create an invoice for a customer in a company you do not belong to.", + }); + } + } + + if (payload.invPaid === undefined) payload.invPaid = false; + payload.invArch = false; + + try { + const created = await Invoice.create(payload); + return res.status(201).json({ message: "Invoice created.", invoice: created }); + } catch (error) { + log.error({ err: error }, 'Invoice.create failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } +}; + +exports.getById = async (req, res) => { + const authKey = req.get('authKey'); + if (!authKey) { + return res.status(403).json({ message: "Authorization key not sent." }); + } + + let invoice; + try { + invoice = await Invoice.findByPk(req.params.id); + } catch (error) { + log.error({ err: error }, 'Invoice.findByPk failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } + if (!invoice || invoice.invArch) { + return res.status(404).json({ message: "Not found." }); + } + + const isMaster = await IsMaster(authKey); + if (!isMaster) { + const authCompanyId = await GetCompanyId(authKey); + const invCompanyId = await GetCompanyIdByCustomerId(invoice.invCustId); + if (authCompanyId === -1 || invCompanyId === -1 || authCompanyId !== invCompanyId) { + return res.status(403).json({ message: "Invalid Authorization Key." }); + } + } + return res.status(200).json({ message: "Found.", invoice }); +}; + +exports.listByCustomer = async (req, res) => { + const authKey = req.get('authKey'); + if (!authKey) { + return res.status(403).json({ message: "Authorization key not sent." }); + } + + const targetCustomerId = Number(req.params.id); + if (!Number.isInteger(targetCustomerId) || targetCustomerId <= 0) { + return res.status(400).json({ message: "Invalid customer id." }); + } + + const isMaster = await IsMaster(authKey); + if (!isMaster) { + const authCompanyId = await GetCompanyId(authKey); + const custCompanyId = await GetCompanyIdByCustomerId(targetCustomerId); + if (authCompanyId === -1 || custCompanyId === -1 || authCompanyId !== custCompanyId) { + return res.status(403).json({ message: "Invalid Authorization Key." }); + } + } + + const requestedLimit = parseInt(req.query.limit, 10); + const limit = Number.isInteger(requestedLimit) && requestedLimit > 0 + ? Math.min(requestedLimit, 500) + : 100; + const requestedOffset = parseInt(req.query.offset, 10); + const offset = Number.isInteger(requestedOffset) && requestedOffset >= 0 + ? requestedOffset + : 0; + + try { + const { count, rows } = await Invoice.findAndCountAll({ + where: { invCustId: targetCustomerId, invArch: false }, + limit, offset, + order: [['invId', 'ASC']], + }); + return res.status(200).json({ + message: "Successfully retrieved invoices for CustomerId " + targetCustomerId, + count, limit, offset, invoices: rows, + }); + } catch (error) { + log.error({ err: error }, 'Invoice.findAndCountAll failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } +}; + +exports.update = async (req, res) => { + const authKey = req.get('authKey'); + if (!authKey) { + return res.status(403).json({ message: "Authorization key not sent." }); + } + + let invoice; + try { + invoice = await Invoice.findByPk(req.params.id); + } catch (error) { + log.error({ err: error }, 'Invoice.findByPk failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } + if (!invoice || invoice.invArch) { + return res.status(404).json({ message: "Not found." }); + } + + const isMaster = await IsMaster(authKey); + if (!isMaster) { + const authCompanyId = await GetCompanyId(authKey); + const invCompanyId = await GetCompanyIdByCustomerId(invoice.invCustId); + if (authCompanyId === -1 || invCompanyId === -1 || authCompanyId !== invCompanyId) { + return res.status(403).json({ message: "Invalid Authorization Key." }); + } + } + + const body = req.body || {}; + const updates = {}; + for (const f of ALLOWED_FIELDS_UPDATE) { + if (body[f] !== undefined) updates[f] = body[f]; + } + if (Object.keys(updates).length === 0) { + return res.status(400).json({ message: "No updatable fields supplied." }); + } + + try { + await invoice.update(updates); + return res.status(200).json({ message: "Updated.", invoice }); + } catch (error) { + log.error({ err: error }, 'Invoice.update failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } +}; + +exports.remove = async (req, res) => { + const authKey = req.get('authKey'); + if (!authKey) { + return res.status(403).json({ message: "Authorization key not sent." }); + } + + let invoice; + try { + invoice = await Invoice.findByPk(req.params.id); + } catch (error) { + log.error({ err: error }, 'Invoice.findByPk failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } + if (!invoice || invoice.invArch) { + return res.status(404).json({ message: "Not found." }); + } + + const isMaster = await IsMaster(authKey); + if (!isMaster) { + const authCompanyId = await GetCompanyId(authKey); + const invCompanyId = await GetCompanyIdByCustomerId(invoice.invCustId); + if (authCompanyId === -1 || invCompanyId === -1 || authCompanyId !== invCompanyId) { + return res.status(403).json({ message: "Invalid Authorization Key." }); + } + } + + try { + await invoice.update({ invArch: true }); + return res.status(200).json({ message: "Archived.", id: invoice.invId }); + } catch (error) { + log.error({ err: error }, 'Invoice archive failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } +}; + +exports._internals = { IsMaster, GetCompanyId, GetCompanyIdByCustomerId }; diff --git a/app/controllers/invoicejobcontroller.js b/app/controllers/invoicejobcontroller.js new file mode 100644 index 0000000..1cb64e1 --- /dev/null +++ b/app/controllers/invoicejobcontroller.js @@ -0,0 +1,228 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark +"use strict"; + +/** + * InvoiceJob controller. Job-scoped — auth resolves via + * injbJobId → Job.jobCustId → Customer.custCompId. + */ + +const db = require('../config/db.config.js'); +const log = require('../config/logger.js'); +const auth = require('../middleware/auth.js'); +const InvoiceJob = db.InvoiceJob; + +const IsMaster = auth.isMaster; +const GetCompanyId = auth.getCompanyId; +const GetCompanyIdByJobId = auth.getCompanyIdByJobId; + +const ALLOWED_FIELDS_CREATE = ['injbInvId', 'injbJobId', 'injbAmount']; +const ALLOWED_FIELDS_UPDATE = ['injbAmount']; + +exports.create = async (req, res) => { + const authKey = req.get('authKey'); + if (!authKey) { + return res.status(403).json({ message: "Authorization key not sent." }); + } + + const body = req.body || {}; + const payload = {}; + for (const f of ALLOWED_FIELDS_CREATE) { + if (body[f] !== undefined) payload[f] = body[f]; + } + if (!payload.injbJobId || !payload.injbInvId) { + return res.status(400).json({ message: "injbInvId and injbJobId are required." }); + } + + const isMaster = await IsMaster(authKey); + if (!isMaster) { + const authCompanyId = await GetCompanyId(authKey); + const jobCompanyId = await GetCompanyIdByJobId(payload.injbJobId); + if (authCompanyId === -1 || jobCompanyId === -1 || authCompanyId !== jobCompanyId) { + return res.status(403).json({ + message: "Cannot create an invoice line for a job in a company you do not belong to.", + }); + } + } + + payload.injbArch = false; + + try { + const created = await InvoiceJob.create(payload); + return res.status(201).json({ message: "Invoice line created.", invoiceJob: created }); + } catch (error) { + log.error({ err: error }, 'InvoiceJob.create failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } +}; + +exports.getById = async (req, res) => { + const authKey = req.get('authKey'); + if (!authKey) { + return res.status(403).json({ message: "Authorization key not sent." }); + } + + let invoiceJob; + try { + invoiceJob = await InvoiceJob.findByPk(req.params.id); + } catch (error) { + log.error({ err: error }, 'InvoiceJob.findByPk failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } + if (!invoiceJob || invoiceJob.injbArch) { + return res.status(404).json({ message: "Not found." }); + } + + const isMaster = await IsMaster(authKey); + if (!isMaster) { + const authCompanyId = await GetCompanyId(authKey); + const jobCompanyId = await GetCompanyIdByJobId(invoiceJob.injbJobId); + if (authCompanyId === -1 || jobCompanyId === -1 || authCompanyId !== jobCompanyId) { + return res.status(403).json({ message: "Invalid Authorization Key." }); + } + } + return res.status(200).json({ message: "Found.", invoiceJob }); +}; + +exports.listByInvoice = async (req, res) => { + const authKey = req.get('authKey'); + if (!authKey) { + return res.status(403).json({ message: "Authorization key not sent." }); + } + + const targetInvoiceId = Number(req.params.id); + if (!Number.isInteger(targetInvoiceId) || targetInvoiceId <= 0) { + return res.status(400).json({ message: "Invalid invoice id." }); + } + + const isMaster = await IsMaster(authKey); + // For non-master, we'd want to verify the invoice's company. To keep + // this lookup cheap we resolve a single row from InvoiceJob and walk + // its job to compId. If the invoice has no lines yet, non-master + // callers get 403 (they can use the Invoice GET to verify access first). + if (!isMaster) { + const authCompanyId = await GetCompanyId(authKey); + if (authCompanyId === -1) { + return res.status(403).json({ message: "Invalid Authorization Key." }); + } + const sample = await InvoiceJob.findOne({ where: { injbInvId: targetInvoiceId } }); + if (sample) { + const jobCompanyId = await GetCompanyIdByJobId(sample.injbJobId); + if (jobCompanyId === -1 || authCompanyId !== jobCompanyId) { + return res.status(403).json({ message: "Invalid Authorization Key." }); + } + } + // sample === null: invoice has no lines yet. We can't verify + // company from InvoiceJob alone, so 403 conservatively. Callers + // who legitimately have access can GET the Invoice first to + // confirm scope, then create lines. + if (!sample) { + return res.status(403).json({ message: "Invalid Authorization Key." }); + } + } + + const requestedLimit = parseInt(req.query.limit, 10); + const limit = Number.isInteger(requestedLimit) && requestedLimit > 0 + ? Math.min(requestedLimit, 500) + : 100; + const requestedOffset = parseInt(req.query.offset, 10); + const offset = Number.isInteger(requestedOffset) && requestedOffset >= 0 + ? requestedOffset + : 0; + + try { + const { count, rows } = await InvoiceJob.findAndCountAll({ + where: { injbInvId: targetInvoiceId, injbArch: false }, + limit, offset, + order: [['injbId', 'ASC']], + }); + return res.status(200).json({ + message: "Successfully retrieved invoice lines for InvoiceId " + targetInvoiceId, + count, limit, offset, invoiceJobs: rows, + }); + } catch (error) { + log.error({ err: error }, 'InvoiceJob.findAndCountAll failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } +}; + +exports.update = async (req, res) => { + const authKey = req.get('authKey'); + if (!authKey) { + return res.status(403).json({ message: "Authorization key not sent." }); + } + + let invoiceJob; + try { + invoiceJob = await InvoiceJob.findByPk(req.params.id); + } catch (error) { + log.error({ err: error }, 'InvoiceJob.findByPk failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } + if (!invoiceJob || invoiceJob.injbArch) { + return res.status(404).json({ message: "Not found." }); + } + + const isMaster = await IsMaster(authKey); + if (!isMaster) { + const authCompanyId = await GetCompanyId(authKey); + const jobCompanyId = await GetCompanyIdByJobId(invoiceJob.injbJobId); + if (authCompanyId === -1 || jobCompanyId === -1 || authCompanyId !== jobCompanyId) { + return res.status(403).json({ message: "Invalid Authorization Key." }); + } + } + + const body = req.body || {}; + const updates = {}; + for (const f of ALLOWED_FIELDS_UPDATE) { + if (body[f] !== undefined) updates[f] = body[f]; + } + if (Object.keys(updates).length === 0) { + return res.status(400).json({ message: "No updatable fields supplied." }); + } + + try { + await invoiceJob.update(updates); + return res.status(200).json({ message: "Updated.", invoiceJob }); + } catch (error) { + log.error({ err: error }, 'InvoiceJob.update failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } +}; + +exports.remove = async (req, res) => { + const authKey = req.get('authKey'); + if (!authKey) { + return res.status(403).json({ message: "Authorization key not sent." }); + } + + let invoiceJob; + try { + invoiceJob = await InvoiceJob.findByPk(req.params.id); + } catch (error) { + log.error({ err: error }, 'InvoiceJob.findByPk failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } + if (!invoiceJob || invoiceJob.injbArch) { + return res.status(404).json({ message: "Not found." }); + } + + const isMaster = await IsMaster(authKey); + if (!isMaster) { + const authCompanyId = await GetCompanyId(authKey); + const jobCompanyId = await GetCompanyIdByJobId(invoiceJob.injbJobId); + if (authCompanyId === -1 || jobCompanyId === -1 || authCompanyId !== jobCompanyId) { + return res.status(403).json({ message: "Invalid Authorization Key." }); + } + } + + try { + await invoiceJob.update({ injbArch: true }); + return res.status(200).json({ message: "Archived.", id: invoiceJob.injbId }); + } catch (error) { + log.error({ err: error }, 'InvoiceJob archive failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } +}; + +exports._internals = { IsMaster, GetCompanyId, GetCompanyIdByJobId }; diff --git a/app/controllers/jobcontroller.js b/app/controllers/jobcontroller.js new file mode 100644 index 0000000..dcbc817 --- /dev/null +++ b/app/controllers/jobcontroller.js @@ -0,0 +1,218 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark +"use strict"; + +/** + * Job controller. Customer-scoped — auth resolves via + * jobCustId → Customer.custCompId. The helper for that lookup lives + * in middleware/auth.js so InvoiceJob / ProductEntry (which scope + * through Job) can chain on it. + */ + +const db = require('../config/db.config.js'); +const log = require('../config/logger.js'); +const auth = require('../middleware/auth.js'); +const Job = db.Job; + +const IsMaster = auth.isMaster; +const GetCompanyId = auth.getCompanyId; +const GetCompanyIdByCustomerId = auth.getCompanyIdByCustomerId; + +const ALLOWED_FIELDS_CREATE = ['jobCustId', 'jobDesc']; +const ALLOWED_FIELDS_UPDATE = ['jobDesc', 'jobInvoiced']; + +exports.create = async (req, res) => { + const authKey = req.get('authKey'); + if (!authKey) { + return res.status(403).json({ message: "Authorization key not sent." }); + } + + const body = req.body || {}; + const payload = {}; + for (const f of ALLOWED_FIELDS_CREATE) { + if (body[f] !== undefined) payload[f] = body[f]; + } + if (!payload.jobCustId) { + return res.status(400).json({ message: "jobCustId is required." }); + } + + const isMaster = await IsMaster(authKey); + if (!isMaster) { + const authCompanyId = await GetCompanyId(authKey); + if (authCompanyId === -1) { + return res.status(403).json({ message: "Invalid Authorization Key." }); + } + const custCompanyId = await GetCompanyIdByCustomerId(payload.jobCustId); + if (custCompanyId === -1 || custCompanyId !== authCompanyId) { + return res.status(403).json({ + message: "Cannot create a job for a customer in a company you do not belong to.", + }); + } + } + + payload.jobArch = false; + payload.jobInvoiced = false; + + try { + const created = await Job.create(payload); + return res.status(201).json({ message: "Job created.", job: created }); + } catch (error) { + log.error({ err: error }, 'Job.create failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } +}; + +exports.getById = async (req, res) => { + const authKey = req.get('authKey'); + if (!authKey) { + return res.status(403).json({ message: "Authorization key not sent." }); + } + + let job; + try { + job = await Job.findByPk(req.params.id); + } catch (error) { + log.error({ err: error }, 'Job.findByPk failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } + if (!job || job.jobArch) { + return res.status(404).json({ message: "Not found." }); + } + + const isMaster = await IsMaster(authKey); + if (!isMaster) { + const authCompanyId = await GetCompanyId(authKey); + const jobCompanyId = await GetCompanyIdByCustomerId(job.jobCustId); + if (authCompanyId === -1 || jobCompanyId === -1 || authCompanyId !== jobCompanyId) { + return res.status(403).json({ message: "Invalid Authorization Key." }); + } + } + return res.status(200).json({ message: "Found.", job }); +}; + +exports.listByCustomer = async (req, res) => { + const authKey = req.get('authKey'); + if (!authKey) { + return res.status(403).json({ message: "Authorization key not sent." }); + } + + const targetCustomerId = Number(req.params.id); + if (!Number.isInteger(targetCustomerId) || targetCustomerId <= 0) { + return res.status(400).json({ message: "Invalid customer id." }); + } + + const isMaster = await IsMaster(authKey); + if (!isMaster) { + const authCompanyId = await GetCompanyId(authKey); + const custCompanyId = await GetCompanyIdByCustomerId(targetCustomerId); + if (authCompanyId === -1 || custCompanyId === -1 || authCompanyId !== custCompanyId) { + return res.status(403).json({ message: "Invalid Authorization Key." }); + } + } + + const requestedLimit = parseInt(req.query.limit, 10); + const limit = Number.isInteger(requestedLimit) && requestedLimit > 0 + ? Math.min(requestedLimit, 500) + : 100; + const requestedOffset = parseInt(req.query.offset, 10); + const offset = Number.isInteger(requestedOffset) && requestedOffset >= 0 + ? requestedOffset + : 0; + + try { + const { count, rows } = await Job.findAndCountAll({ + where: { jobCustId: targetCustomerId, jobArch: false }, + limit, + offset, + order: [['jobId', 'ASC']], + }); + return res.status(200).json({ + message: "Successfully retrieved jobs for CustomerId " + targetCustomerId, + count, limit, offset, jobs: rows, + }); + } catch (error) { + log.error({ err: error }, 'Job.findAndCountAll failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } +}; + +exports.update = async (req, res) => { + const authKey = req.get('authKey'); + if (!authKey) { + return res.status(403).json({ message: "Authorization key not sent." }); + } + + let job; + try { + job = await Job.findByPk(req.params.id); + } catch (error) { + log.error({ err: error }, 'Job.findByPk failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } + if (!job || job.jobArch) { + return res.status(404).json({ message: "Not found." }); + } + + const isMaster = await IsMaster(authKey); + if (!isMaster) { + const authCompanyId = await GetCompanyId(authKey); + const jobCompanyId = await GetCompanyIdByCustomerId(job.jobCustId); + if (authCompanyId === -1 || jobCompanyId === -1 || authCompanyId !== jobCompanyId) { + return res.status(403).json({ message: "Invalid Authorization Key." }); + } + } + + const body = req.body || {}; + const updates = {}; + for (const f of ALLOWED_FIELDS_UPDATE) { + if (body[f] !== undefined) updates[f] = body[f]; + } + if (Object.keys(updates).length === 0) { + return res.status(400).json({ message: "No updatable fields supplied." }); + } + + try { + await job.update(updates); + return res.status(200).json({ message: "Updated.", job }); + } catch (error) { + log.error({ err: error }, 'Job.update failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } +}; + +exports.remove = async (req, res) => { + const authKey = req.get('authKey'); + if (!authKey) { + return res.status(403).json({ message: "Authorization key not sent." }); + } + + let job; + try { + job = await Job.findByPk(req.params.id); + } catch (error) { + log.error({ err: error }, 'Job.findByPk failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } + if (!job || job.jobArch) { + return res.status(404).json({ message: "Not found." }); + } + + const isMaster = await IsMaster(authKey); + if (!isMaster) { + const authCompanyId = await GetCompanyId(authKey); + const jobCompanyId = await GetCompanyIdByCustomerId(job.jobCustId); + if (authCompanyId === -1 || jobCompanyId === -1 || authCompanyId !== jobCompanyId) { + return res.status(403).json({ message: "Invalid Authorization Key." }); + } + } + + try { + await job.update({ jobArch: true }); + return res.status(200).json({ message: "Archived.", id: job.jobId }); + } catch (error) { + log.error({ err: error }, 'Job archive failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } +}; + +exports._internals = { IsMaster, GetCompanyId, GetCompanyIdByCustomerId }; diff --git a/app/controllers/productentrycontroller.js b/app/controllers/productentrycontroller.js new file mode 100644 index 0000000..fbed49a --- /dev/null +++ b/app/controllers/productentrycontroller.js @@ -0,0 +1,206 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark +"use strict"; + +const db = require('../config/db.config.js'); +const log = require('../config/logger.js'); +const auth = require('../middleware/auth.js'); +const ProductEntry = db.ProductEntry; + +const IsMaster = auth.isMaster; +const GetCompanyId = auth.getCompanyId; +const GetCompanyIdByJobId = auth.getCompanyIdByJobId; + +const ALLOWED_FIELDS_CREATE = ['pentQty', 'pentJobId', 'pentInvtId', 'pentTaxable']; +const ALLOWED_FIELDS_UPDATE = ['pentQty', 'pentInvtId', 'pentTaxable']; + +exports.create = async (req, res) => { + const authKey = req.get('authKey'); + if (!authKey) { + return res.status(403).json({ message: "Authorization key not sent." }); + } + + const body = req.body || {}; + const payload = {}; + for (const f of ALLOWED_FIELDS_CREATE) { + if (body[f] !== undefined) payload[f] = body[f]; + } + if (!payload.pentJobId) { + return res.status(400).json({ message: "pentJobId is required." }); + } + + const isMaster = await IsMaster(authKey); + if (!isMaster) { + const authCompanyId = await GetCompanyId(authKey); + const jobCompanyId = await GetCompanyIdByJobId(payload.pentJobId); + if (authCompanyId === -1 || jobCompanyId === -1 || authCompanyId !== jobCompanyId) { + return res.status(403).json({ + message: "Cannot create a product entry for a job in a company you do not belong to.", + }); + } + } + + payload.penArch = false; + + try { + const created = await ProductEntry.create(payload); + return res.status(201).json({ message: "Product entry created.", productEntry: created }); + } catch (error) { + log.error({ err: error }, 'ProductEntry.create failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } +}; + +exports.getById = async (req, res) => { + const authKey = req.get('authKey'); + if (!authKey) { + return res.status(403).json({ message: "Authorization key not sent." }); + } + + let productEntry; + try { + productEntry = await ProductEntry.findByPk(req.params.id); + } catch (error) { + log.error({ err: error }, 'ProductEntry.findByPk failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } + if (!productEntry || productEntry.penArch) { + return res.status(404).json({ message: "Not found." }); + } + + const isMaster = await IsMaster(authKey); + if (!isMaster) { + const authCompanyId = await GetCompanyId(authKey); + const jobCompanyId = await GetCompanyIdByJobId(productEntry.pentJobId); + if (authCompanyId === -1 || jobCompanyId === -1 || authCompanyId !== jobCompanyId) { + return res.status(403).json({ message: "Invalid Authorization Key." }); + } + } + return res.status(200).json({ message: "Found.", productEntry }); +}; + +exports.listByJob = async (req, res) => { + const authKey = req.get('authKey'); + if (!authKey) { + return res.status(403).json({ message: "Authorization key not sent." }); + } + + const targetJobId = Number(req.params.id); + if (!Number.isInteger(targetJobId) || targetJobId <= 0) { + return res.status(400).json({ message: "Invalid job id." }); + } + + const isMaster = await IsMaster(authKey); + if (!isMaster) { + const authCompanyId = await GetCompanyId(authKey); + const jobCompanyId = await GetCompanyIdByJobId(targetJobId); + if (authCompanyId === -1 || jobCompanyId === -1 || authCompanyId !== jobCompanyId) { + return res.status(403).json({ message: "Invalid Authorization Key." }); + } + } + + const requestedLimit = parseInt(req.query.limit, 10); + const limit = Number.isInteger(requestedLimit) && requestedLimit > 0 + ? Math.min(requestedLimit, 500) + : 100; + const requestedOffset = parseInt(req.query.offset, 10); + const offset = Number.isInteger(requestedOffset) && requestedOffset >= 0 + ? requestedOffset + : 0; + + try { + const { count, rows } = await ProductEntry.findAndCountAll({ + where: { pentJobId: targetJobId, penArch: false }, + limit, offset, + order: [['pentId', 'ASC']], + }); + return res.status(200).json({ + message: "Successfully retrieved product entries for JobId " + targetJobId, + count, limit, offset, productEntries: rows, + }); + } catch (error) { + log.error({ err: error }, 'ProductEntry.findAndCountAll failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } +}; + +exports.update = async (req, res) => { + const authKey = req.get('authKey'); + if (!authKey) { + return res.status(403).json({ message: "Authorization key not sent." }); + } + + let productEntry; + try { + productEntry = await ProductEntry.findByPk(req.params.id); + } catch (error) { + log.error({ err: error }, 'ProductEntry.findByPk failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } + if (!productEntry || productEntry.penArch) { + return res.status(404).json({ message: "Not found." }); + } + + const isMaster = await IsMaster(authKey); + if (!isMaster) { + const authCompanyId = await GetCompanyId(authKey); + const jobCompanyId = await GetCompanyIdByJobId(productEntry.pentJobId); + if (authCompanyId === -1 || jobCompanyId === -1 || authCompanyId !== jobCompanyId) { + return res.status(403).json({ message: "Invalid Authorization Key." }); + } + } + + const body = req.body || {}; + const updates = {}; + for (const f of ALLOWED_FIELDS_UPDATE) { + if (body[f] !== undefined) updates[f] = body[f]; + } + if (Object.keys(updates).length === 0) { + return res.status(400).json({ message: "No updatable fields supplied." }); + } + + try { + await productEntry.update(updates); + return res.status(200).json({ message: "Updated.", productEntry }); + } catch (error) { + log.error({ err: error }, 'ProductEntry.update failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } +}; + +exports.remove = async (req, res) => { + const authKey = req.get('authKey'); + if (!authKey) { + return res.status(403).json({ message: "Authorization key not sent." }); + } + + let productEntry; + try { + productEntry = await ProductEntry.findByPk(req.params.id); + } catch (error) { + log.error({ err: error }, 'ProductEntry.findByPk failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } + if (!productEntry || productEntry.penArch) { + return res.status(404).json({ message: "Not found." }); + } + + const isMaster = await IsMaster(authKey); + if (!isMaster) { + const authCompanyId = await GetCompanyId(authKey); + const jobCompanyId = await GetCompanyIdByJobId(productEntry.pentJobId); + if (authCompanyId === -1 || jobCompanyId === -1 || authCompanyId !== jobCompanyId) { + return res.status(403).json({ message: "Invalid Authorization Key." }); + } + } + + try { + await productEntry.update({ penArch: true }); + return res.status(200).json({ message: "Archived.", id: productEntry.pentId }); + } catch (error) { + log.error({ err: error }, 'ProductEntry archive failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } +}; + +exports._internals = { IsMaster, GetCompanyId, GetCompanyIdByJobId }; diff --git a/app/controllers/versioninfocontroller.js b/app/controllers/versioninfocontroller.js new file mode 100644 index 0000000..8201e1f --- /dev/null +++ b/app/controllers/versioninfocontroller.js @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark +"use strict"; + +/** + * VersionInfo controller. + * + * Global table — no compId. Reads are open to any auth'd caller; + * mutations (POST, PATCH, DELETE) require a master key. The schema + * has no archive column, so DELETE physically destroys the row. + */ + +const db = require('../config/db.config.js'); +const log = require('../config/logger.js'); +const auth = require('../middleware/auth.js'); +const VersionInfo = db.VersionInfo; + +const IsMaster = auth.isMaster; + +const ALLOWED_FIELDS = ['viVersion', 'viDate']; + +async function requireMaster(authKey, res) { + if (!authKey) { + res.status(403).json({ message: "Authorization key not sent." }); + return false; + } + const isMaster = await IsMaster(authKey); + if (!isMaster) { + res.status(403).json({ message: "Only master keys may mutate VersionInfo." }); + return false; + } + return true; +} + +exports.create = async (req, res) => { + const authKey = req.get('authKey'); + if (!(await requireMaster(authKey, res))) return; + + const body = req.body || {}; + const payload = {}; + for (const f of ALLOWED_FIELDS) { + if (body[f] !== undefined) payload[f] = body[f]; + } + try { + const created = await VersionInfo.create(payload); + return res.status(201).json({ message: "VersionInfo created.", versionInfo: created }); + } catch (error) { + log.error({ err: error }, 'VersionInfo.create failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } +}; + +exports.getById = async (req, res) => { + const authKey = req.get('authKey'); + if (!authKey) { + return res.status(403).json({ message: "Authorization key not sent." }); + } + try { + const v = await VersionInfo.findByPk(req.params.id); + if (!v) return res.status(404).json({ message: "Not found." }); + return res.status(200).json({ message: "Found.", versionInfo: v }); + } catch (error) { + log.error({ err: error }, 'VersionInfo.findByPk failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } +}; + +exports.list = async (req, res) => { + const authKey = req.get('authKey'); + if (!authKey) { + return res.status(403).json({ message: "Authorization key not sent." }); + } + + const requestedLimit = parseInt(req.query.limit, 10); + const limit = Number.isInteger(requestedLimit) && requestedLimit > 0 + ? Math.min(requestedLimit, 500) + : 100; + const requestedOffset = parseInt(req.query.offset, 10); + const offset = Number.isInteger(requestedOffset) && requestedOffset >= 0 + ? requestedOffset + : 0; + + try { + const { count, rows } = await VersionInfo.findAndCountAll({ + limit, offset, + order: [['viDate', 'DESC']], + }); + return res.status(200).json({ + message: "Successfully retrieved version info", + count, limit, offset, versionInfos: rows, + }); + } catch (error) { + log.error({ err: error }, 'VersionInfo.findAndCountAll failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } +}; + +exports.update = async (req, res) => { + const authKey = req.get('authKey'); + if (!(await requireMaster(authKey, res))) return; + + let v; + try { + v = await VersionInfo.findByPk(req.params.id); + } catch (error) { + log.error({ err: error }, 'VersionInfo.findByPk failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } + if (!v) return res.status(404).json({ message: "Not found." }); + + const body = req.body || {}; + const updates = {}; + for (const f of ALLOWED_FIELDS) { + if (body[f] !== undefined) updates[f] = body[f]; + } + if (Object.keys(updates).length === 0) { + return res.status(400).json({ message: "No updatable fields supplied." }); + } + + try { + await v.update(updates); + return res.status(200).json({ message: "Updated.", versionInfo: v }); + } catch (error) { + log.error({ err: error }, 'VersionInfo.update failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } +}; + +exports.remove = async (req, res) => { + const authKey = req.get('authKey'); + if (!(await requireMaster(authKey, res))) return; + + let v; + try { + v = await VersionInfo.findByPk(req.params.id); + } catch (error) { + log.error({ err: error }, 'VersionInfo.findByPk failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } + if (!v) return res.status(404).json({ message: "Not found." }); + + try { + await v.destroy(); + return res.status(200).json({ message: "Deleted.", id: req.params.id }); + } catch (error) { + log.error({ err: error }, 'VersionInfo.destroy failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } +}; + +exports._internals = { IsMaster }; diff --git a/app/controllers/workercontroller.js b/app/controllers/workercontroller.js new file mode 100644 index 0000000..e9098a7 --- /dev/null +++ b/app/controllers/workercontroller.js @@ -0,0 +1,260 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark +"use strict"; + +const db = require('../config/db.config.js'); +const log = require('../config/logger.js'); +const auth = require('../middleware/auth.js'); +const Worker = db.Worker; + +const IsMaster = auth.isMaster; +const GetCompanyId = auth.getCompanyId; + +const ALLOWED_FIELDS_CREATE = [ + 'workerFName', 'workerLName', 'workerTitle', + 'workerDefaultBillType', 'workerCompId', +]; +const ALLOWED_FIELDS_UPDATE = [ + 'workerFName', 'workerLName', 'workerTitle', 'workerDefaultBillType', +]; + +/** + * POST /v1/worker — create a worker in a company. + * + * Master keys must supply workerCompId. Non-master keys: workerCompId + * defaults to the authKey's owning company; supplying a different one + * is a 403. + */ +exports.create = async (req, res) => { + const authKey = req.get('authKey'); + if (!authKey) { + return res.status(403).json({ message: "Authorization key not sent." }); + } + + let isAuthKeyMasterKey; + try { + isAuthKeyMasterKey = await IsMaster(authKey); + } catch (error) { + log.error({ err: error }, 'IsMaster failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } + + const body = req.body || {}; + const payload = {}; + for (const f of ALLOWED_FIELDS_CREATE) { + if (body[f] !== undefined) payload[f] = body[f]; + } + + if (!isAuthKeyMasterKey) { + let authKeyCompanyId; + try { + authKeyCompanyId = await GetCompanyId(authKey); + } catch (error) { + log.error({ err: error }, 'GetCompanyId failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } + if (authKeyCompanyId === -1) { + return res.status(403).json({ message: "Invalid Authorization Key." }); + } + if (payload.workerCompId !== undefined && Number(payload.workerCompId) !== authKeyCompanyId) { + return res.status(403).json({ + message: "Cannot create a worker for a company you do not belong to.", + }); + } + payload.workerCompId = authKeyCompanyId; + } else { + if (payload.workerCompId === undefined || Number(payload.workerCompId) <= 0) { + return res.status(400).json({ + message: "Master-key requests must specify workerCompId.", + }); + } + } + + payload.workerArch = false; + + try { + const created = await Worker.create(payload); + return res.status(201).json({ + message: "Worker created.", + worker: created, + }); + } catch (error) { + log.error({ err: error }, 'Worker.create failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } +}; + +/** + * GET /v1/worker/:id — fetch a single worker by id. + * + * Non-master keys may only read workers whose workerCompId matches + * their own akCompanyId. + */ +exports.getById = async (req, res) => { + const authKey = req.get('authKey'); + if (!authKey) { + return res.status(403).json({ message: "Authorization key not sent." }); + } + + let worker; + try { + worker = await Worker.findByPk(req.params.id); + } catch (error) { + log.error({ err: error }, 'Worker.findByPk failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } + if (!worker || worker.workerArch) { + return res.status(404).json({ message: "Not found." }); + } + + const isMaster = await IsMaster(authKey); + if (!isMaster) { + const companyId = await GetCompanyId(authKey); + if (companyId === -1 || worker.workerCompId !== companyId) { + return res.status(403).json({ message: "Invalid Authorization Key." }); + } + } + return res.status(200).json({ message: "Found.", worker }); +}; + +/** + * GET /v1/worker/bycompany/:id — list workers for a company (paginated). + * + * Non-master keys: :id must match the authKey's owning company. + */ +exports.listByCompany = async (req, res) => { + const authKey = req.get('authKey'); + if (!authKey) { + return res.status(403).json({ message: "Authorization key not sent." }); + } + + const targetCompanyId = Number(req.params.id); + if (!Number.isInteger(targetCompanyId) || targetCompanyId <= 0) { + return res.status(400).json({ message: "Invalid company id." }); + } + + const isMaster = await IsMaster(authKey); + if (!isMaster) { + const companyId = await GetCompanyId(authKey); + if (companyId === -1 || companyId !== targetCompanyId) { + return res.status(403).json({ message: "Invalid Authorization Key." }); + } + } + + const requestedLimit = parseInt(req.query.limit, 10); + const limit = Number.isInteger(requestedLimit) && requestedLimit > 0 + ? Math.min(requestedLimit, 500) + : 100; + const requestedOffset = parseInt(req.query.offset, 10); + const offset = Number.isInteger(requestedOffset) && requestedOffset >= 0 + ? requestedOffset + : 0; + + try { + const { count, rows } = await Worker.findAndCountAll({ + where: { workerCompId: targetCompanyId, workerArch: false }, + limit, + offset, + order: [['workerId', 'ASC']], + }); + return res.status(200).json({ + message: "Successfully retrieved workers with CompanyId " + targetCompanyId, + count, + limit, + offset, + workers: rows, + }); + } catch (error) { + log.error({ err: error }, 'Worker.findAndCountAll failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } +}; + +/** + * PATCH /v1/worker/:id — partial update. + * + * workerCompId / workerArch are server-managed and not user-settable here. + */ +exports.update = async (req, res) => { + const authKey = req.get('authKey'); + if (!authKey) { + return res.status(403).json({ message: "Authorization key not sent." }); + } + + let worker; + try { + worker = await Worker.findByPk(req.params.id); + } catch (error) { + log.error({ err: error }, 'Worker.findByPk failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } + if (!worker || worker.workerArch) { + return res.status(404).json({ message: "Not found." }); + } + + const isMaster = await IsMaster(authKey); + if (!isMaster) { + const companyId = await GetCompanyId(authKey); + if (companyId === -1 || worker.workerCompId !== companyId) { + return res.status(403).json({ message: "Invalid Authorization Key." }); + } + } + + const body = req.body || {}; + const updates = {}; + for (const f of ALLOWED_FIELDS_UPDATE) { + if (body[f] !== undefined) updates[f] = body[f]; + } + if (Object.keys(updates).length === 0) { + return res.status(400).json({ message: "No updatable fields supplied." }); + } + + try { + await worker.update(updates); + return res.status(200).json({ message: "Updated.", worker }); + } catch (error) { + log.error({ err: error }, 'Worker.update failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } +}; + +/** + * DELETE /v1/worker/:id — soft-delete (sets workerArch = true). + * + * Workers are never physically removed via the API. + */ +exports.remove = async (req, res) => { + const authKey = req.get('authKey'); + if (!authKey) { + return res.status(403).json({ message: "Authorization key not sent." }); + } + + let worker; + try { + worker = await Worker.findByPk(req.params.id); + } catch (error) { + log.error({ err: error }, 'Worker.findByPk failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } + if (!worker || worker.workerArch) { + return res.status(404).json({ message: "Not found." }); + } + + const isMaster = await IsMaster(authKey); + if (!isMaster) { + const companyId = await GetCompanyId(authKey); + if (companyId === -1 || worker.workerCompId !== companyId) { + return res.status(403).json({ message: "Invalid Authorization Key." }); + } + } + + try { + await worker.update({ workerArch: true }); + return res.status(200).json({ message: "Archived.", id: worker.workerId }); + } catch (error) { + log.error({ err: error }, 'Worker archive failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } +}; + +exports._internals = { IsMaster, GetCompanyId }; diff --git a/app/middleware/auth.js b/app/middleware/auth.js index 15aed57..7f26256 100644 --- a/app/middleware/auth.js +++ b/app/middleware/auth.js @@ -62,6 +62,58 @@ async function getCompanyId(authKey) { } } +/** + * Resolve a customer id to its owning company id. + * + * Used by entities that don't have their own *CompId column and instead + * scope auth through their Customer relation (Job, Invoice, + * CustomerPayment). Returns -1 on missing / archived / lookup failure + * so callers can use the same `=== -1` sentinel as getCompanyId(). + */ +async function getCompanyIdByCustomerId(customerId) { + const idStr = customerId == null ? '' : String(customerId); + if (idStr.length === 0 || idStr === '0') return -1; + try { + const r = await db.sequelize.query( + 'SELECT "custCompId" FROM "dbo"."Customer" WHERE "custId" = ? AND "custArch" = false;', + { replacements: [customerId], type: sequelize.QueryTypes.SELECT }, + ); + if (!r || r.length === 0) return -1; + const cid = r[0].custCompId; + return typeof cid === 'number' && cid > 0 ? cid : -1; + } catch (error) { + log.error({ err: error }, 'auth.getCompanyIdByCustomerId query failed'); + return -1; + } +} + +/** + * Resolve a job id to its owning company id. + * + * Job has no direct *CompId — it scopes through Customer + * (Job.jobCustId → Customer.custCompId). Used by InvoiceJob and + * ProductEntry whose own FKs point into Job. + */ +async function getCompanyIdByJobId(jobId) { + const idStr = jobId == null ? '' : String(jobId); + if (idStr.length === 0 || idStr === '0') return -1; + try { + const r = await db.sequelize.query( + `SELECT c."custCompId" + FROM "dbo"."Job" j + JOIN "dbo"."Customer" c ON c."custId" = j."jobCustId" + WHERE j."jobId" = ? AND j."jobArch" = false AND c."custArch" = false;`, + { replacements: [jobId], type: sequelize.QueryTypes.SELECT }, + ); + if (!r || r.length === 0) return -1; + const cid = r[0].custCompId; + return typeof cid === 'number' && cid > 0 ? cid : -1; + } catch (error) { + log.error({ err: error }, 'auth.getCompanyIdByJobId query failed'); + return -1; + } +} + /** * Express middleware: ensures the authKey header is present and * stashes it on req.authKey. Does NOT validate the key against the @@ -114,6 +166,8 @@ async function resolveAuth(req, res, next) { module.exports = { isMaster, getCompanyId, + getCompanyIdByCustomerId, + getCompanyIdByJobId, requireAuthKey, resolveAuth, }; diff --git a/app/migrations/20260517000000-purchase-orders-and-archive-columns.js b/app/migrations/20260517000000-purchase-orders-and-archive-columns.js new file mode 100644 index 0000000..c500bc8 --- /dev/null +++ b/app/migrations/20260517000000-purchase-orders-and-archive-columns.js @@ -0,0 +1,187 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark +// +// Adds the four PurchaseOrder/InventoryTransaction tables from the +// original SQL Server BACPAC that were omitted from +// `setup/TimeTracker.sql` (which only covered the tables the v1.0 API +// surface actually used). Also retrofits the missing `*Arch` columns +// onto two existing tables so the soft-delete pattern works uniformly +// across the full API: +// +// - InventoryItem → adds invitArch +// - InvoiceJob → adds injbArch +// +// New tables: +// - InventoryTransactions +// - PurchaseOrderHeaders +// - PurchaseOrderLines +// - PurchaseOrderVendors +// +// Column types come from the BACPAC schema (Microsoft.Data.Tools.Schema +// for SQL Server). Where the BACPAC declared a TEXT we use Postgres +// `TEXT`; bools map to BOOLEAN; doubles to DOUBLE PRECISION; dates to +// timestamp(3) without time zone (matching what the existing +// setup/TimeTracker.sql does for timestamp columns). + +'use strict'; + +const SCHEMA = 'dbo'; + +module.exports = { + /** @param {import('sequelize').QueryInterface} queryInterface */ + async up(queryInterface, Sequelize) { + await queryInterface.sequelize.transaction(async (t) => { + // 1. Retrofit archive columns on existing tables. + await queryInterface.addColumn( + { tableName: 'InventoryItem', schema: SCHEMA }, + 'invitArch', + { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + { transaction: t }, + ); + await queryInterface.addColumn( + { tableName: 'InvoiceJob', schema: SCHEMA }, + 'injbArch', + { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + { transaction: t }, + ); + + // 2. InventoryTransactions — movement log for inventory. + await queryInterface.createTable( + { tableName: 'InventoryTransactions', schema: SCHEMA }, + { + invtId: { + type: Sequelize.INTEGER, + allowNull: false, + primaryKey: true, + autoIncrement: true, + }, + invtCompanyId: { type: Sequelize.INTEGER, allowNull: false }, + invtDirection: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 }, + invtArch: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: false }, + invtInitId: { type: Sequelize.INTEGER, allowNull: false }, + }, + { transaction: t }, + ); + + // 3. PurchaseOrderVendors — vendors POs are issued to. + // Created before headers because the header FK-references it. + await queryInterface.createTable( + { tableName: 'PurchaseOrderVendors', schema: SCHEMA }, + { + povId: { + type: Sequelize.INTEGER, + allowNull: false, + primaryKey: true, + autoIncrement: true, + }, + povName: { type: Sequelize.TEXT, allowNull: false }, + povMailingAddress1: { type: Sequelize.TEXT, allowNull: false }, + povMailingAddress2: { type: Sequelize.TEXT }, + povMailingCity: { type: Sequelize.TEXT, allowNull: false }, + povMailingState: { type: Sequelize.TEXT }, + povMailingCountry: { type: Sequelize.TEXT }, + povMailingZip: { type: Sequelize.TEXT }, + povBillingAddress1: { type: Sequelize.TEXT }, + povBillingAddress2: { type: Sequelize.TEXT }, + povBillingCity: { type: Sequelize.TEXT }, + povBillingState: { type: Sequelize.TEXT }, + povBillingCountry: { type: Sequelize.TEXT }, + povBillingZip: { type: Sequelize.TEXT }, + povPhone: { type: Sequelize.TEXT }, + povEMail: { type: Sequelize.TEXT }, + povCompId: { type: Sequelize.INTEGER, allowNull: false }, + povArch: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: false }, + }, + { transaction: t }, + ); + + // 4. PurchaseOrderHeaders — one row per PO. + await queryInterface.createTable( + { tableName: 'PurchaseOrderHeaders', schema: SCHEMA }, + { + pohId: { + type: Sequelize.INTEGER, + allowNull: false, + primaryKey: true, + autoIncrement: true, + }, + pohDate: { type: 'timestamp(3) without time zone', allowNull: false }, + pohReference: { type: Sequelize.TEXT, allowNull: false }, + pohTerms: { type: Sequelize.TEXT, allowNull: false }, + pohPovId: { type: Sequelize.INTEGER, allowNull: false }, + pohArch: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: false }, + }, + { transaction: t }, + ); + + // 5. PurchaseOrderLines — line items per PO header. + await queryInterface.createTable( + { tableName: 'PurchaseOrderLines', schema: SCHEMA }, + { + polId: { + type: Sequelize.INTEGER, + allowNull: false, + primaryKey: true, + autoIncrement: true, + }, + polpoh: { type: Sequelize.INTEGER, allowNull: false }, + polItemDesc: { type: Sequelize.TEXT, allowNull: false }, + polQty: { type: Sequelize.DOUBLE, allowNull: false }, + polPrice: { type: Sequelize.DOUBLE, allowNull: false }, + polInvtId: { type: Sequelize.INTEGER, allowNull: false }, + polArch: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: false }, + }, + { transaction: t }, + ); + }); + }, + + /** @param {import('sequelize').QueryInterface} queryInterface */ + async down(queryInterface /*, Sequelize */) { + await queryInterface.sequelize.transaction(async (t) => { + // Drop tables in reverse dependency order. Lines reference + // headers; headers reference vendors; transactions stand alone. + await queryInterface.dropTable( + { tableName: 'PurchaseOrderLines', schema: SCHEMA }, + { transaction: t }, + ); + await queryInterface.dropTable( + { tableName: 'PurchaseOrderHeaders', schema: SCHEMA }, + { transaction: t }, + ); + await queryInterface.dropTable( + { tableName: 'PurchaseOrderVendors', schema: SCHEMA }, + { transaction: t }, + ); + await queryInterface.dropTable( + { tableName: 'InventoryTransactions', schema: SCHEMA }, + { transaction: t }, + ); + + // Roll back the archive columns. Note: any rows that were + // soft-deleted via these columns become "live" again after + // rollback. There's no way to preserve their archived state + // without keeping the column, so this is the documented + // behavior. If that's a problem, archive the data before + // running `migrate:undo`. + await queryInterface.removeColumn( + { tableName: 'InvoiceJob', schema: SCHEMA }, + 'injbArch', + { transaction: t }, + ); + await queryInterface.removeColumn( + { tableName: 'InventoryItem', schema: SCHEMA }, + 'invitArch', + { transaction: t }, + ); + }); + }, +}; diff --git a/app/models/billingtype.model.js b/app/models/billingtype.model.js new file mode 100644 index 0000000..24bd002 --- /dev/null +++ b/app/models/billingtype.model.js @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark +"use strict"; + +/** + * BillingType — a named hourly rate that a Worker can default to. + * + * Soft-deletes via btArch. Scoped to a company via btCompId. + */ +module.exports = (sequelize, Sequelize) => { + const BillingType = sequelize.define('BillingType', { + btId: { + field: 'btId', + type: Sequelize.INTEGER, + autoIncrement: true, + primaryKey: true, + }, + btName: { + field: 'btName', + type: Sequelize.TEXT, + allowNull: false, + }, + btHourlyRate: { + field: 'btHourlyRate', + type: Sequelize.DOUBLE, + allowNull: false, + }, + btArch: { + field: 'btArch', + type: Sequelize.BOOLEAN, + defaultValue: false, + }, + btCompId: { + field: 'btCompId', + type: Sequelize.INTEGER, + allowNull: false, + }, + }, { + tableName: 'BillingType', + timestamps: false, + }); + + return BillingType; +}; diff --git a/app/models/company.model.js b/app/models/company.model.js new file mode 100644 index 0000000..2e0de8f --- /dev/null +++ b/app/models/company.model.js @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark +"use strict"; + +/** + * Company — the top-level tenant. compId is referenced by every + * other entity's *CompId column for auth scoping. + * + * Only master keys may create or hard-delete companies. Non-master + * keys see / patch only their own company (akCompanyId match). + * Soft-deletes via compArch. + */ +module.exports = (sequelize, Sequelize) => { + const Company = sequelize.define('Company', { + compId: { + field: 'compId', + type: Sequelize.INTEGER, + autoIncrement: true, + primaryKey: true, + }, + compName: { + field: 'compName', + type: Sequelize.TEXT, + allowNull: false, + }, + compAddress1: { field: 'compAddress1', type: Sequelize.TEXT }, + compAddress2: { field: 'compAddress2', type: Sequelize.TEXT }, + compCity: { field: 'compCity', type: Sequelize.TEXT }, + compState: { field: 'compState', type: Sequelize.STRING(2) }, + compZip: { field: 'compZip', type: Sequelize.TEXT }, + compPhone: { field: 'compPhone', type: Sequelize.STRING(32) }, + compEmail: { field: 'compEmail', type: Sequelize.TEXT }, + compArch: { + field: 'compArch', + type: Sequelize.BOOLEAN, + defaultValue: false, + }, + }, { + tableName: 'Company', + timestamps: false, + }); + + return Company; +}; diff --git a/app/models/customerpayment.model.js b/app/models/customerpayment.model.js new file mode 100644 index 0000000..80f21d8 --- /dev/null +++ b/app/models/customerpayment.model.js @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark +"use strict"; + +/** + * CustomerPayment — a payment received from a Customer. + * + * Scope via cpayCustId → Customer.custCompId. Soft-deletes via cpayArch. + */ +module.exports = (sequelize, Sequelize) => { + const CustomerPayment = sequelize.define('CustomerPayment', { + cpayId: { + field: 'cpayId', + type: Sequelize.INTEGER, + autoIncrement: true, + primaryKey: true, + }, + cpayCustId: { + field: 'cpayCustId', + type: Sequelize.INTEGER, + allowNull: false, + }, + cpayDescription: { + field: 'cpayDescription', + type: Sequelize.TEXT, + }, + cpayDate: { + field: 'cpayDate', + type: Sequelize.DATEONLY, + allowNull: false, + }, + cpayAmount: { + field: 'cpayAmount', + type: Sequelize.DOUBLE, + allowNull: false, + }, + cpayArch: { + field: 'cpayArch', + type: Sequelize.BOOLEAN, + defaultValue: false, + }, + }, { + tableName: 'CustomerPayment', + timestamps: false, + }); + + return CustomerPayment; +}; diff --git a/app/models/inventoryitem.model.js b/app/models/inventoryitem.model.js new file mode 100644 index 0000000..387725c --- /dev/null +++ b/app/models/inventoryitem.model.js @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark +"use strict"; + +/** + * InventoryItem — a sellable / consumable item tracked by quantity. + * + * Soft-deletes via invitArch (added in the same migration that landed + * the missing PurchaseOrder* tables; the original PG schema in + * setup/TimeTracker.sql lacked an archive column). + */ +module.exports = (sequelize, Sequelize) => { + const InventoryItem = sequelize.define('InventoryItem', { + invitId: { + field: 'invitId', + type: Sequelize.INTEGER, + autoIncrement: true, + primaryKey: true, + }, + invitDescription: { + field: 'invitDescription', + type: Sequelize.TEXT, + allowNull: false, + }, + invitQty: { + field: 'invitQty', + type: Sequelize.DOUBLE, + allowNull: false, + }, + invitCompId: { + field: 'invitCompId', + type: Sequelize.INTEGER, + allowNull: false, + }, + invitArch: { + field: 'invitArch', + type: Sequelize.BOOLEAN, + defaultValue: false, + }, + }, { + tableName: 'InventoryItem', + timestamps: false, + }); + + return InventoryItem; +}; diff --git a/app/models/invoice.model.js b/app/models/invoice.model.js new file mode 100644 index 0000000..195daf9 --- /dev/null +++ b/app/models/invoice.model.js @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark +"use strict"; + +/** + * Invoice — a bill issued to a Customer. + * + * Scope via invCustId → Customer.custCompId. Soft-deletes via invArch. + * invPaid is a separate flag from invArch — paid invoices are still + * read-only via the API, never auto-archived. + */ +module.exports = (sequelize, Sequelize) => { + const Invoice = sequelize.define('Invoice', { + invId: { + field: 'invId', + type: Sequelize.INTEGER, + autoIncrement: true, + primaryKey: true, + }, + invDate: { + field: 'invDate', + type: Sequelize.DATEONLY, + allowNull: false, + }, + invDueDate: { + field: 'invDueDate', + type: Sequelize.DATEONLY, + allowNull: false, + }, + invPaid: { + field: 'invPaid', + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + invArch: { + field: 'invArch', + type: Sequelize.BOOLEAN, + defaultValue: false, + }, + invCustId: { + field: 'invCustId', + type: Sequelize.INTEGER, + allowNull: false, + }, + }, { + tableName: 'Invoice', + timestamps: false, + }); + + return Invoice; +}; diff --git a/app/models/invoicejob.model.js b/app/models/invoicejob.model.js new file mode 100644 index 0000000..ab81ab0 --- /dev/null +++ b/app/models/invoicejob.model.js @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark +"use strict"; + +/** + * InvoiceJob — a line item on an Invoice referencing a Job. + * + * Scope via injbJobId → Job → Customer → custCompId (see + * auth.getCompanyIdByJobId). Soft-deletes via injbArch (column added + * in the same migration that filled in the rest of the missing + * archive columns and the four PurchaseOrder/InventoryTransaction + * tables). + */ +module.exports = (sequelize, Sequelize) => { + const InvoiceJob = sequelize.define('InvoiceJob', { + injbId: { + field: 'injbId', + type: Sequelize.INTEGER, + autoIncrement: true, + primaryKey: true, + }, + injbInvId: { + field: 'injbInvId', + type: Sequelize.INTEGER, + allowNull: false, + }, + injbJobId: { + field: 'injbJobId', + type: Sequelize.INTEGER, + allowNull: false, + }, + injbAmount: { + field: 'injbAmount', + type: Sequelize.DOUBLE, + allowNull: false, + }, + injbArch: { + field: 'injbArch', + type: Sequelize.BOOLEAN, + defaultValue: false, + }, + }, { + tableName: 'InvoiceJob', + timestamps: false, + }); + + return InvoiceJob; +}; diff --git a/app/models/job.model.js b/app/models/job.model.js new file mode 100644 index 0000000..e45f795 --- /dev/null +++ b/app/models/job.model.js @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark +"use strict"; + +/** + * Job — a unit of billable work for a Customer. + * + * No direct compId — scope is resolved via jobCustId → Customer.custCompId + * (see auth.getCompanyIdByCustomerId). Soft-deletes via jobArch. + * jobInvoiced flips true once an InvoiceJob exists for the row. + */ +module.exports = (sequelize, Sequelize) => { + const Job = sequelize.define('Job', { + jobId: { + field: 'jobId', + type: Sequelize.INTEGER, + autoIncrement: true, + primaryKey: true, + }, + jobCustId: { + field: 'jobCustId', + type: Sequelize.INTEGER, + allowNull: false, + }, + jobDesc: { + field: 'jobDesc', + type: Sequelize.TEXT, + allowNull: false, + }, + jobArch: { + field: 'jobArch', + type: Sequelize.BOOLEAN, + defaultValue: false, + }, + jobInvoiced: { + field: 'jobInvoiced', + type: Sequelize.BOOLEAN, + defaultValue: false, + }, + }, { + tableName: 'Job', + timestamps: false, + }); + + return Job; +}; diff --git a/app/models/productentry.model.js b/app/models/productentry.model.js new file mode 100644 index 0000000..e3a11da --- /dev/null +++ b/app/models/productentry.model.js @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark +"use strict"; + +/** + * ProductEntry — a line of inventory consumed on a Job. + * + * Scope via pentJobId → Job → Customer → custCompId. The existing PG + * schema spells the archive column "penArch" (note: three letters, + * not the entity-wide "pent" prefix); we match that spelling rather + * than rename, to keep the model in sync with what + * setup/TimeTracker.sql actually creates. + */ +module.exports = (sequelize, Sequelize) => { + const ProductEntry = sequelize.define('ProductEntry', { + pentId: { + field: 'pentId', + type: Sequelize.INTEGER, + autoIncrement: true, + primaryKey: true, + }, + pentQty: { + field: 'pentQty', + type: Sequelize.INTEGER, + allowNull: false, + }, + pentJobId: { + field: 'pentJobId', + type: Sequelize.INTEGER, + allowNull: false, + }, + pentInvtId: { + field: 'pentInvtId', + type: Sequelize.INTEGER, + allowNull: false, + }, + penArch: { + field: 'penArch', + type: Sequelize.BOOLEAN, + defaultValue: false, + }, + pentTaxable: { + field: 'pentTaxable', + type: Sequelize.BOOLEAN, + }, + }, { + tableName: 'ProductEntry', + timestamps: false, + }); + + return ProductEntry; +}; diff --git a/app/models/versioninfo.model.js b/app/models/versioninfo.model.js new file mode 100644 index 0000000..998dc70 --- /dev/null +++ b/app/models/versioninfo.model.js @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark +"use strict"; + +/** + * VersionInfo — global schema/build version record. Not company-scoped; + * any auth'd caller can read it. Mutations require a master key. No + * archive column in the underlying table, so DELETE is a hard destroy. + */ +module.exports = (sequelize, Sequelize) => { + const VersionInfo = sequelize.define('VersionInfo', { + viId: { + field: 'viId', + type: Sequelize.INTEGER, + autoIncrement: true, + primaryKey: true, + }, + viVersion: { + field: 'viVersion', + type: Sequelize.TEXT, + allowNull: false, + }, + viDate: { + field: 'viDate', + type: Sequelize.DATE, + allowNull: false, + }, + }, { + tableName: 'VersionInfo', + timestamps: false, + }); + + return VersionInfo; +}; diff --git a/app/models/worker.model.js b/app/models/worker.model.js new file mode 100644 index 0000000..87a838e --- /dev/null +++ b/app/models/worker.model.js @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark +"use strict"; + +/** + * Worker — a person who can log time against jobs in a company. + * + * Follows the same 2-3-char column-prefix convention as Customer ("cust") + * and TimeEntry ("te"): every column starts with "worker". Quoted in + * queries because Postgres lowercases unquoted identifiers and the rest + * of the schema is camelCase. + * + * Soft-deletes via workerArch (matches Customer.custArch). Workers are + * scoped to a company via workerCompId; only master keys or keys whose + * akCompanyId equals workerCompId may see/mutate the row. + */ +module.exports = (sequelize, Sequelize) => { + const Worker = sequelize.define('Worker', { + workerId: { + field: 'workerId', + type: Sequelize.INTEGER, + autoIncrement: true, + primaryKey: true, + }, + workerFName: { + field: 'workerFName', + type: Sequelize.TEXT, + allowNull: false, + }, + workerLName: { + field: 'workerLName', + type: Sequelize.TEXT, + allowNull: false, + }, + workerTitle: { + field: 'workerTitle', + type: Sequelize.TEXT, + allowNull: false, + }, + workerDefaultBillType: { + field: 'workerDefaultBillType', + type: Sequelize.INTEGER, + allowNull: false, + }, + workerArch: { + field: 'workerArch', + type: Sequelize.BOOLEAN, + defaultValue: false, + }, + workerCompId: { + field: 'workerCompId', + type: Sequelize.INTEGER, + allowNull: false, + }, + }, { + tableName: 'Worker', + timestamps: false, + }); + + return Worker; +}; diff --git a/app/routers/router.js b/app/routers/router.js index 44f80b4..6e5369a 100644 --- a/app/routers/router.js +++ b/app/routers/router.js @@ -8,10 +8,30 @@ const swaggerUi = require('swagger-ui-express'); const customer = require('../controllers/customercontroller.js'); const health = require('../controllers/healthcontroller.js'); const timeEntry = require('../controllers/timeentrycontroller.js'); +const worker = require('../controllers/workercontroller.js'); +const billingType = require('../controllers/billingtypecontroller.js'); +const inventoryItem = require('../controllers/inventoryitemcontroller.js'); +const company = require('../controllers/companycontroller.js'); +const job = require('../controllers/jobcontroller.js'); +const invoice = require('../controllers/invoicecontroller.js'); +const customerPayment = require('../controllers/customerpaymentcontroller.js'); +const invoiceJob = require('../controllers/invoicejobcontroller.js'); +const productEntry = require('../controllers/productentrycontroller.js'); +const versionInfo = require('../controllers/versioninfocontroller.js'); const openapiSpec = require('../config/openapi.js'); const v = require('../middleware/validate.js'); const customerSchemas = require('../schemas/customer.schema.js'); const timeEntrySchemas = require('../schemas/timeentry.schema.js'); +const workerSchemas = require('../schemas/worker.schema.js'); +const billingTypeSchemas = require('../schemas/billingtype.schema.js'); +const inventoryItemSchemas = require('../schemas/inventoryitem.schema.js'); +const companySchemas = require('../schemas/company.schema.js'); +const jobSchemas = require('../schemas/job.schema.js'); +const invoiceSchemas = require('../schemas/invoice.schema.js'); +const customerPaymentSchemas = require('../schemas/customerpayment.schema.js'); +const invoiceJobSchemas = require('../schemas/invoicejob.schema.js'); +const productEntrySchemas = require('../schemas/productentry.schema.js'); +const versionInfoSchemas = require('../schemas/versioninfo.schema.js'); // Health / readiness probe. No auth required — only exposes liveness // of the API process and reachability of the database. @@ -73,4 +93,293 @@ router.delete( timeEntry.remove, ); +// v1 worker routes. +router.post( + '/v1/worker', + v.body(workerSchemas.createWorkerBody), + worker.create, +); +router.get( + '/v1/worker/bycompany/:id', + v.params(workerSchemas.intIdParam), + v.query(workerSchemas.listByCompanyQuery), + worker.listByCompany, +); +router.get( + '/v1/worker/:id', + v.params(workerSchemas.intIdParam), + worker.getById, +); +router.patch( + '/v1/worker/:id', + v.params(workerSchemas.intIdParam), + v.body(workerSchemas.updateWorkerBody), + worker.update, +); +router.delete( + '/v1/worker/:id', + v.params(workerSchemas.intIdParam), + worker.remove, +); + +// v1 billingtype routes. +router.post( + '/v1/billingtype', + v.body(billingTypeSchemas.createBillingTypeBody), + billingType.create, +); +router.get( + '/v1/billingtype/bycompany/:id', + v.params(billingTypeSchemas.intIdParam), + v.query(billingTypeSchemas.listByCompanyQuery), + billingType.listByCompany, +); +router.get( + '/v1/billingtype/:id', + v.params(billingTypeSchemas.intIdParam), + billingType.getById, +); +router.patch( + '/v1/billingtype/:id', + v.params(billingTypeSchemas.intIdParam), + v.body(billingTypeSchemas.updateBillingTypeBody), + billingType.update, +); +router.delete( + '/v1/billingtype/:id', + v.params(billingTypeSchemas.intIdParam), + billingType.remove, +); + +// v1 inventoryitem routes. +router.post( + '/v1/inventoryitem', + v.body(inventoryItemSchemas.createInventoryItemBody), + inventoryItem.create, +); +router.get( + '/v1/inventoryitem/bycompany/:id', + v.params(inventoryItemSchemas.intIdParam), + v.query(inventoryItemSchemas.listByCompanyQuery), + inventoryItem.listByCompany, +); +router.get( + '/v1/inventoryitem/:id', + v.params(inventoryItemSchemas.intIdParam), + inventoryItem.getById, +); +router.patch( + '/v1/inventoryitem/:id', + v.params(inventoryItemSchemas.intIdParam), + v.body(inventoryItemSchemas.updateInventoryItemBody), + inventoryItem.update, +); +router.delete( + '/v1/inventoryitem/:id', + v.params(inventoryItemSchemas.intIdParam), + inventoryItem.remove, +); + +// v1 company routes. Company is special — see companycontroller.js. +router.post( + '/v1/company', + v.body(companySchemas.createCompanyBody), + company.create, +); +router.get( + '/v1/company', + v.query(companySchemas.listQuery), + company.list, +); +router.get( + '/v1/company/:id', + v.params(companySchemas.intIdParam), + company.getById, +); +router.patch( + '/v1/company/:id', + v.params(companySchemas.intIdParam), + v.body(companySchemas.updateCompanyBody), + company.update, +); +router.delete( + '/v1/company/:id', + v.params(companySchemas.intIdParam), + company.remove, +); + +// v1 job routes. Customer-scoped via jobCustId → Customer.custCompId. +router.post( + '/v1/job', + v.body(jobSchemas.createJobBody), + job.create, +); +router.get( + '/v1/job/bycustomer/:id', + v.params(jobSchemas.intIdParam), + v.query(jobSchemas.listByCustomerQuery), + job.listByCustomer, +); +router.get( + '/v1/job/:id', + v.params(jobSchemas.intIdParam), + job.getById, +); +router.patch( + '/v1/job/:id', + v.params(jobSchemas.intIdParam), + v.body(jobSchemas.updateJobBody), + job.update, +); +router.delete( + '/v1/job/:id', + v.params(jobSchemas.intIdParam), + job.remove, +); + +// v1 invoice routes. +router.post( + '/v1/invoice', + v.body(invoiceSchemas.createInvoiceBody), + invoice.create, +); +router.get( + '/v1/invoice/bycustomer/:id', + v.params(invoiceSchemas.intIdParam), + v.query(invoiceSchemas.listByCustomerQuery), + invoice.listByCustomer, +); +router.get( + '/v1/invoice/:id', + v.params(invoiceSchemas.intIdParam), + invoice.getById, +); +router.patch( + '/v1/invoice/:id', + v.params(invoiceSchemas.intIdParam), + v.body(invoiceSchemas.updateInvoiceBody), + invoice.update, +); +router.delete( + '/v1/invoice/:id', + v.params(invoiceSchemas.intIdParam), + invoice.remove, +); + +// v1 customerpayment routes. +router.post( + '/v1/customerpayment', + v.body(customerPaymentSchemas.createCustomerPaymentBody), + customerPayment.create, +); +router.get( + '/v1/customerpayment/bycustomer/:id', + v.params(customerPaymentSchemas.intIdParam), + v.query(customerPaymentSchemas.listByCustomerQuery), + customerPayment.listByCustomer, +); +router.get( + '/v1/customerpayment/:id', + v.params(customerPaymentSchemas.intIdParam), + customerPayment.getById, +); +router.patch( + '/v1/customerpayment/:id', + v.params(customerPaymentSchemas.intIdParam), + v.body(customerPaymentSchemas.updateCustomerPaymentBody), + customerPayment.update, +); +router.delete( + '/v1/customerpayment/:id', + v.params(customerPaymentSchemas.intIdParam), + customerPayment.remove, +); + +// v1 invoicejob routes. Job-scoped via injbJobId → Job.jobCustId → Customer.custCompId. +router.post( + '/v1/invoicejob', + v.body(invoiceJobSchemas.createInvoiceJobBody), + invoiceJob.create, +); +router.get( + '/v1/invoicejob/byinvoice/:id', + v.params(invoiceJobSchemas.intIdParam), + v.query(invoiceJobSchemas.listByInvoiceQuery), + invoiceJob.listByInvoice, +); +router.get( + '/v1/invoicejob/:id', + v.params(invoiceJobSchemas.intIdParam), + invoiceJob.getById, +); +router.patch( + '/v1/invoicejob/:id', + v.params(invoiceJobSchemas.intIdParam), + v.body(invoiceJobSchemas.updateInvoiceJobBody), + invoiceJob.update, +); +router.delete( + '/v1/invoicejob/:id', + v.params(invoiceJobSchemas.intIdParam), + invoiceJob.remove, +); + +// v1 productentry routes. +router.post( + '/v1/productentry', + v.body(productEntrySchemas.createProductEntryBody), + productEntry.create, +); +router.get( + '/v1/productentry/byjob/:id', + v.params(productEntrySchemas.intIdParam), + v.query(productEntrySchemas.listByJobQuery), + productEntry.listByJob, +); +router.get( + '/v1/productentry/:id', + v.params(productEntrySchemas.intIdParam), + productEntry.getById, +); +router.patch( + '/v1/productentry/:id', + v.params(productEntrySchemas.intIdParam), + v.body(productEntrySchemas.updateProductEntryBody), + productEntry.update, +); +router.delete( + '/v1/productentry/:id', + v.params(productEntrySchemas.intIdParam), + productEntry.remove, +); + +// v1 versioninfo routes. Global table; reads open to any authKey, +// mutations require a master key. +router.post( + '/v1/versioninfo', + v.body(versionInfoSchemas.createVersionInfoBody), + versionInfo.create, +); +router.get( + '/v1/versioninfo', + v.query(versionInfoSchemas.listQuery), + versionInfo.list, +); +router.get( + '/v1/versioninfo/:id', + v.params(versionInfoSchemas.intIdParam), + versionInfo.getById, +); +router.patch( + '/v1/versioninfo/:id', + v.params(versionInfoSchemas.intIdParam), + v.body(versionInfoSchemas.updateVersionInfoBody), + versionInfo.update, +); +router.delete( + '/v1/versioninfo/:id', + v.params(versionInfoSchemas.intIdParam), + versionInfo.remove, +); + module.exports = router; diff --git a/app/schemas/billingtype.schema.js b/app/schemas/billingtype.schema.js new file mode 100644 index 0000000..67430be --- /dev/null +++ b/app/schemas/billingtype.schema.js @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark +"use strict"; + +const { z } = require('zod'); + +const intIdParam = z.object({ + id: z.coerce.number().int().positive(), +}); + +const createBillingTypeBody = z.object({ + btName: z.string().min(1).max(255), + btHourlyRate: z.coerce.number().nonnegative(), + btCompId: z.coerce.number().int().positive().optional(), +}).strict({ + message: 'Unexpected field in body. Whitelist: btName, btHourlyRate, btCompId.', +}); + +const updateBillingTypeBody = z.object({ + btName: z.string().min(1).max(255).optional(), + btHourlyRate: z.coerce.number().nonnegative().optional(), +}).strict({ + message: 'Unexpected field in body. Whitelist: btName, btHourlyRate.', +}); + +const listByCompanyQuery = z.object({ + limit: z.coerce.number().int().positive().max(500).optional(), + offset: z.coerce.number().int().nonnegative().optional(), +}).strict({ + message: 'Unexpected query parameter. Allowed: limit, offset.', +}); + +module.exports = { + intIdParam, + createBillingTypeBody, + updateBillingTypeBody, + listByCompanyQuery, +}; diff --git a/app/schemas/company.schema.js b/app/schemas/company.schema.js new file mode 100644 index 0000000..7b7d844 --- /dev/null +++ b/app/schemas/company.schema.js @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark +"use strict"; + +const { z } = require('zod'); + +const intIdParam = z.object({ + id: z.coerce.number().int().positive(), +}); + +const createCompanyBody = z.object({ + compName: z.string().min(1).max(255), + compAddress1: z.string().max(255).optional(), + compAddress2: z.string().max(255).optional(), + compCity: z.string().max(255).optional(), + compState: z.string().length(2).optional(), + compZip: z.string().max(32).optional(), + compPhone: z.string().max(32).optional(), + compEmail: z.string().email().max(255).optional(), +}).strict({ + message: 'Unexpected field in body. Whitelist: compName, compAddress1, compAddress2, compCity, compState, compZip, compPhone, compEmail.', +}); + +const updateCompanyBody = z.object({ + compName: z.string().min(1).max(255).optional(), + compAddress1: z.string().max(255).optional(), + compAddress2: z.string().max(255).optional(), + compCity: z.string().max(255).optional(), + compState: z.string().length(2).optional(), + compZip: z.string().max(32).optional(), + compPhone: z.string().max(32).optional(), + compEmail: z.string().email().max(255).optional(), +}).strict({ + message: 'Unexpected field in body. Whitelist: compName, compAddress1, compAddress2, compCity, compState, compZip, compPhone, compEmail.', +}); + +const listQuery = z.object({ + limit: z.coerce.number().int().positive().max(500).optional(), + offset: z.coerce.number().int().nonnegative().optional(), +}).strict({ + message: 'Unexpected query parameter. Allowed: limit, offset.', +}); + +module.exports = { + intIdParam, + createCompanyBody, + updateCompanyBody, + listQuery, +}; diff --git a/app/schemas/customerpayment.schema.js b/app/schemas/customerpayment.schema.js new file mode 100644 index 0000000..5c33af9 --- /dev/null +++ b/app/schemas/customerpayment.schema.js @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark +"use strict"; + +const { z } = require('zod'); + +const intIdParam = z.object({ + id: z.coerce.number().int().positive(), +}); + +const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, { + message: 'Must be an ISO 8601 date (YYYY-MM-DD).', +}); + +const createCustomerPaymentBody = z.object({ + cpayCustId: z.coerce.number().int().positive(), + cpayDescription: z.string().max(10000).optional(), + cpayDate: isoDate, + cpayAmount: z.coerce.number(), +}).strict({ + message: 'Unexpected field in body. Whitelist: cpayCustId, cpayDescription, cpayDate, cpayAmount.', +}); + +const updateCustomerPaymentBody = z.object({ + cpayDescription: z.string().max(10000).optional(), + cpayDate: isoDate.optional(), + cpayAmount: z.coerce.number().optional(), +}).strict({ + message: 'Unexpected field in body. Whitelist: cpayDescription, cpayDate, cpayAmount.', +}); + +const listByCustomerQuery = z.object({ + limit: z.coerce.number().int().positive().max(500).optional(), + offset: z.coerce.number().int().nonnegative().optional(), +}).strict({ + message: 'Unexpected query parameter. Allowed: limit, offset.', +}); + +module.exports = { + intIdParam, + createCustomerPaymentBody, + updateCustomerPaymentBody, + listByCustomerQuery, +}; diff --git a/app/schemas/inventoryitem.schema.js b/app/schemas/inventoryitem.schema.js new file mode 100644 index 0000000..9929b3f --- /dev/null +++ b/app/schemas/inventoryitem.schema.js @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark +"use strict"; + +const { z } = require('zod'); + +const intIdParam = z.object({ + id: z.coerce.number().int().positive(), +}); + +const createInventoryItemBody = z.object({ + invitDescription: z.string().min(1).max(1000), + invitQty: z.coerce.number(), + invitCompId: z.coerce.number().int().positive().optional(), +}).strict({ + message: 'Unexpected field in body. Whitelist: invitDescription, invitQty, invitCompId.', +}); + +const updateInventoryItemBody = z.object({ + invitDescription: z.string().min(1).max(1000).optional(), + invitQty: z.coerce.number().optional(), +}).strict({ + message: 'Unexpected field in body. Whitelist: invitDescription, invitQty.', +}); + +const listByCompanyQuery = z.object({ + limit: z.coerce.number().int().positive().max(500).optional(), + offset: z.coerce.number().int().nonnegative().optional(), +}).strict({ + message: 'Unexpected query parameter. Allowed: limit, offset.', +}); + +module.exports = { + intIdParam, + createInventoryItemBody, + updateInventoryItemBody, + listByCompanyQuery, +}; diff --git a/app/schemas/invoice.schema.js b/app/schemas/invoice.schema.js new file mode 100644 index 0000000..49f6090 --- /dev/null +++ b/app/schemas/invoice.schema.js @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark +"use strict"; + +const { z } = require('zod'); + +const intIdParam = z.object({ + id: z.coerce.number().int().positive(), +}); + +const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, { + message: 'Must be an ISO 8601 date (YYYY-MM-DD).', +}); + +const createInvoiceBody = z.object({ + invCustId: z.coerce.number().int().positive(), + invDate: isoDate, + invDueDate: isoDate, + invPaid: z.boolean().optional(), +}).strict({ + message: 'Unexpected field in body. Whitelist: invCustId, invDate, invDueDate, invPaid.', +}); + +const updateInvoiceBody = z.object({ + invDate: isoDate.optional(), + invDueDate: isoDate.optional(), + invPaid: z.boolean().optional(), +}).strict({ + message: 'Unexpected field in body. Whitelist: invDate, invDueDate, invPaid.', +}); + +const listByCustomerQuery = z.object({ + limit: z.coerce.number().int().positive().max(500).optional(), + offset: z.coerce.number().int().nonnegative().optional(), +}).strict({ + message: 'Unexpected query parameter. Allowed: limit, offset.', +}); + +module.exports = { + intIdParam, + createInvoiceBody, + updateInvoiceBody, + listByCustomerQuery, +}; diff --git a/app/schemas/invoicejob.schema.js b/app/schemas/invoicejob.schema.js new file mode 100644 index 0000000..1a61da3 --- /dev/null +++ b/app/schemas/invoicejob.schema.js @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark +"use strict"; + +const { z } = require('zod'); + +const intIdParam = z.object({ + id: z.coerce.number().int().positive(), +}); + +const createInvoiceJobBody = z.object({ + injbInvId: z.coerce.number().int().positive(), + injbJobId: z.coerce.number().int().positive(), + injbAmount: z.coerce.number(), +}).strict({ + message: 'Unexpected field in body. Whitelist: injbInvId, injbJobId, injbAmount.', +}); + +const updateInvoiceJobBody = z.object({ + injbAmount: z.coerce.number().optional(), +}).strict({ + message: 'Unexpected field in body. Whitelist: injbAmount.', +}); + +const listByInvoiceQuery = z.object({ + limit: z.coerce.number().int().positive().max(500).optional(), + offset: z.coerce.number().int().nonnegative().optional(), +}).strict({ + message: 'Unexpected query parameter. Allowed: limit, offset.', +}); + +module.exports = { + intIdParam, + createInvoiceJobBody, + updateInvoiceJobBody, + listByInvoiceQuery, +}; diff --git a/app/schemas/job.schema.js b/app/schemas/job.schema.js new file mode 100644 index 0000000..e989550 --- /dev/null +++ b/app/schemas/job.schema.js @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark +"use strict"; + +const { z } = require('zod'); + +const intIdParam = z.object({ + id: z.coerce.number().int().positive(), +}); + +const createJobBody = z.object({ + jobCustId: z.coerce.number().int().positive(), + jobDesc: z.string().min(1).max(10000), +}).strict({ + message: 'Unexpected field in body. Whitelist: jobCustId, jobDesc.', +}); + +const updateJobBody = z.object({ + jobDesc: z.string().min(1).max(10000).optional(), + jobInvoiced: z.boolean().optional(), +}).strict({ + message: 'Unexpected field in body. Whitelist: jobDesc, jobInvoiced.', +}); + +const listByCustomerQuery = z.object({ + limit: z.coerce.number().int().positive().max(500).optional(), + offset: z.coerce.number().int().nonnegative().optional(), +}).strict({ + message: 'Unexpected query parameter. Allowed: limit, offset.', +}); + +module.exports = { + intIdParam, + createJobBody, + updateJobBody, + listByCustomerQuery, +}; diff --git a/app/schemas/productentry.schema.js b/app/schemas/productentry.schema.js new file mode 100644 index 0000000..8eedc0b --- /dev/null +++ b/app/schemas/productentry.schema.js @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark +"use strict"; + +const { z } = require('zod'); + +const intIdParam = z.object({ + id: z.coerce.number().int().positive(), +}); + +const createProductEntryBody = z.object({ + pentQty: z.coerce.number().int(), + pentJobId: z.coerce.number().int().positive(), + pentInvtId: z.coerce.number().int().positive(), + pentTaxable: z.boolean().optional(), +}).strict({ + message: 'Unexpected field in body. Whitelist: pentQty, pentJobId, pentInvtId, pentTaxable.', +}); + +const updateProductEntryBody = z.object({ + pentQty: z.coerce.number().int().optional(), + pentInvtId: z.coerce.number().int().positive().optional(), + pentTaxable: z.boolean().optional(), +}).strict({ + message: 'Unexpected field in body. Whitelist: pentQty, pentInvtId, pentTaxable.', +}); + +const listByJobQuery = z.object({ + limit: z.coerce.number().int().positive().max(500).optional(), + offset: z.coerce.number().int().nonnegative().optional(), +}).strict({ + message: 'Unexpected query parameter. Allowed: limit, offset.', +}); + +module.exports = { + intIdParam, + createProductEntryBody, + updateProductEntryBody, + listByJobQuery, +}; diff --git a/app/schemas/versioninfo.schema.js b/app/schemas/versioninfo.schema.js new file mode 100644 index 0000000..90b865d --- /dev/null +++ b/app/schemas/versioninfo.schema.js @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark +"use strict"; + +const { z } = require('zod'); + +const intIdParam = z.object({ + id: z.coerce.number().int().positive(), +}); + +const isoDatetime = z.string().datetime({ + offset: true, + message: 'Must be an ISO 8601 datetime.', +}); + +const createVersionInfoBody = z.object({ + viVersion: z.string().min(1).max(255), + viDate: isoDatetime, +}).strict({ + message: 'Unexpected field in body. Whitelist: viVersion, viDate.', +}); + +const updateVersionInfoBody = z.object({ + viVersion: z.string().min(1).max(255).optional(), + viDate: isoDatetime.optional(), +}).strict({ + message: 'Unexpected field in body. Whitelist: viVersion, viDate.', +}); + +const listQuery = z.object({ + limit: z.coerce.number().int().positive().max(500).optional(), + offset: z.coerce.number().int().nonnegative().optional(), +}).strict({ + message: 'Unexpected query parameter. Allowed: limit, offset.', +}); + +module.exports = { + intIdParam, + createVersionInfoBody, + updateVersionInfoBody, + listQuery, +}; diff --git a/app/schemas/worker.schema.js b/app/schemas/worker.schema.js new file mode 100644 index 0000000..9e0ce0c --- /dev/null +++ b/app/schemas/worker.schema.js @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark +"use strict"; + +const { z } = require('zod'); + +const intIdParam = z.object({ + id: z.coerce.number().int().positive(), +}); + +/** + * POST /v1/worker body. workerCompId is optional for non-master keys + * (defaults to authKey's company) and required for master keys + * (controller enforces). workerFName / workerLName / workerTitle / + * workerDefaultBillType are required. + * + * Server-managed fields (workerId, workerArch) are not accepted. + */ +const createWorkerBody = z.object({ + workerFName: z.string().min(1).max(255), + workerLName: z.string().min(1).max(255), + workerTitle: z.string().min(1).max(255), + workerDefaultBillType: z.coerce.number().int().positive(), + workerCompId: z.coerce.number().int().positive().optional(), +}).strict({ + message: 'Unexpected field in body. Whitelist: workerFName, workerLName, workerTitle, workerDefaultBillType, workerCompId.', +}); + +/** + * PATCH /v1/worker/:id body. None of the fields are required — + * a PATCH is a partial update — but at least one must be present. + * workerCompId is not patchable (would amount to "move worker to + * another company"; out of scope and would break auth invariants). + */ +const updateWorkerBody = z.object({ + workerFName: z.string().min(1).max(255).optional(), + workerLName: z.string().min(1).max(255).optional(), + workerTitle: z.string().min(1).max(255).optional(), + workerDefaultBillType: z.coerce.number().int().positive().optional(), +}).strict({ + message: 'Unexpected field in body. Whitelist: workerFName, workerLName, workerTitle, workerDefaultBillType.', +}); + +const listByCompanyQuery = z.object({ + limit: z.coerce.number().int().positive().max(500).optional(), + offset: z.coerce.number().int().nonnegative().optional(), +}).strict({ + message: 'Unexpected query parameter. Allowed: limit, offset.', +}); + +module.exports = { + intIdParam, + createWorkerBody, + updateWorkerBody, + listByCompanyQuery, +}; diff --git a/tests/api/billingtype.test.js b/tests/api/billingtype.test.js new file mode 100644 index 0000000..796a52c --- /dev/null +++ b/tests/api/billingtype.test.js @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark +// +// HTTP smoke tests for /v1/billingtype/*. Same approach as worker.test.js. + +import { describe, test, expect, vi, beforeAll } from 'vitest'; +import request from 'supertest'; +import express from 'express'; + +vi.mock('../../app/config/db.config.js', () => ({ + sequelize: { + query: vi.fn().mockResolvedValue([]), + QueryTypes: { SELECT: 'SELECT' }, + }, + Sequelize: {}, + Customer: {}, + TimeEntry: {}, + Worker: {}, + BillingType: { + findByPk: vi.fn().mockResolvedValue(null), + findAndCountAll: vi.fn().mockResolvedValue({ count: 0, rows: [] }), + create: vi.fn().mockResolvedValue({ btId: 1 }), + }, + InventoryItem: {}, + Company: {}, + ApiKey: {}, + ApiMaster: {}, +})); + +let app; + +beforeAll(async () => { + const router = (await import('../../app/routers/router.js')).default + || require('../../app/routers/router.js'); + app = express(); + app.use(express.json()); + app.use('/', router); +}); + +describe('BillingType auth contract', () => { + test('GET /v1/billingtype/:id returns 403 when authKey missing', async () => { + const res = await request(app).get('/v1/billingtype/1'); + expect(res.status).toBe(403); + }); + test('POST /v1/billingtype returns 403 when authKey missing', async () => { + const res = await request(app) + .post('/v1/billingtype') + .send({ btName: 'Standard', btHourlyRate: 100 }); + expect(res.status).toBe(403); + }); + test('GET /v1/billingtype/bycompany/:id returns 403 when authKey missing', async () => { + expect((await request(app).get('/v1/billingtype/bycompany/1')).status).toBe(403); + }); + test('PATCH /v1/billingtype/:id returns 403 when authKey missing', async () => { + expect((await request(app).patch('/v1/billingtype/1').send({ btName: 'New' })).status).toBe(403); + }); + test('DELETE /v1/billingtype/:id returns 403 when authKey missing', async () => { + expect((await request(app).delete('/v1/billingtype/1')).status).toBe(403); + }); +}); + +describe('BillingType route mounting', () => { + test('routes are mounted (not 404)', async () => { + const res = await request(app).get('/v1/billingtype/1').set('authKey', 'any'); + expect(res.status).not.toBe(404); + }); +}); + +describe('BillingType body validation', () => { + test('POST rejects unknown field with 400', async () => { + const res = await request(app) + .post('/v1/billingtype') + .set('authKey', 'any') + .send({ btName: 'X', btHourlyRate: 1, bogus: 'no' }); + expect(res.status).toBe(400); + }); + test('POST rejects missing required btHourlyRate with 400', async () => { + const res = await request(app) + .post('/v1/billingtype') + .set('authKey', 'any') + .send({ btName: 'Standard' }); + expect(res.status).toBe(400); + }); +}); diff --git a/tests/api/company.test.js b/tests/api/company.test.js new file mode 100644 index 0000000..b96fcbe --- /dev/null +++ b/tests/api/company.test.js @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark +// +// HTTP smoke tests for /v1/company/*. Company is special: master-only +// for POST/DELETE/list, scoped for GET/PATCH. + +import { describe, test, expect, vi, beforeAll } from 'vitest'; +import request from 'supertest'; +import express from 'express'; + +vi.mock('../../app/config/db.config.js', () => ({ + sequelize: { + query: vi.fn().mockResolvedValue([]), + QueryTypes: { SELECT: 'SELECT' }, + }, + Sequelize: {}, + Customer: {}, TimeEntry: {}, Worker: {}, BillingType: {}, InventoryItem: {}, + Company: { + findByPk: vi.fn().mockResolvedValue(null), + findAndCountAll: vi.fn().mockResolvedValue({ count: 0, rows: [] }), + create: vi.fn().mockResolvedValue({ compId: 1 }), + }, + ApiKey: {}, ApiMaster: {}, +})); + +let app; + +beforeAll(async () => { + const router = (await import('../../app/routers/router.js')).default + || require('../../app/routers/router.js'); + app = express(); + app.use(express.json()); + app.use('/', router); +}); + +describe('Company auth contract', () => { + test('GET /v1/company/:id returns 403 when authKey missing', async () => { + expect((await request(app).get('/v1/company/1')).status).toBe(403); + }); + test('GET /v1/company returns 403 when authKey missing', async () => { + expect((await request(app).get('/v1/company')).status).toBe(403); + }); + test('POST /v1/company returns 403 when authKey missing', async () => { + const res = await request(app).post('/v1/company').send({ compName: 'X' }); + expect(res.status).toBe(403); + }); + test('PATCH /v1/company/:id returns 403 when authKey missing', async () => { + expect((await request(app).patch('/v1/company/1').send({ compName: 'X' })).status).toBe(403); + }); + test('DELETE /v1/company/:id returns 403 when authKey missing', async () => { + expect((await request(app).delete('/v1/company/1')).status).toBe(403); + }); + + // Non-master keys hit "Only master keys may create companies" path + // (mock returns empty so IsMaster -> false). + test('POST /v1/company returns 403 for non-master keys', async () => { + const res = await request(app) + .post('/v1/company') + .set('authKey', 'not-a-master') + .send({ compName: 'X' }); + expect(res.status).toBe(403); + expect(res.body.message).toMatch(/master/i); + }); +}); + +describe('Company route mounting', () => { + test('GET /v1/company/:id mounted (not 404)', async () => { + const res = await request(app).get('/v1/company/1').set('authKey', 'any'); + expect(res.status).not.toBe(404); + }); + test('GET /v1/company mounted', async () => { + const res = await request(app).get('/v1/company').set('authKey', 'any'); + expect(res.status).not.toBe(404); + }); +}); + +describe('Company body validation', () => { + test('POST rejects unknown field with 400', async () => { + const res = await request(app) + .post('/v1/company') + .set('authKey', 'any') + .send({ compName: 'X', bogus: 'no' }); + expect(res.status).toBe(400); + }); + test('POST rejects missing compName with 400', async () => { + const res = await request(app) + .post('/v1/company') + .set('authKey', 'any') + .send({ compCity: 'Lincoln' }); + expect(res.status).toBe(400); + }); +}); diff --git a/tests/api/customerpayment.test.js b/tests/api/customerpayment.test.js new file mode 100644 index 0000000..d1714cf --- /dev/null +++ b/tests/api/customerpayment.test.js @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark + +import { describe, test, expect, vi, beforeAll } from 'vitest'; +import request from 'supertest'; +import express from 'express'; + +vi.mock('../../app/config/db.config.js', () => ({ + sequelize: { query: vi.fn().mockResolvedValue([]), QueryTypes: { SELECT: 'SELECT' } }, + Sequelize: {}, + Customer: {}, TimeEntry: {}, Worker: {}, BillingType: {}, InventoryItem: {}, Company: {}, Job: {}, Invoice: {}, + CustomerPayment: { + findByPk: vi.fn().mockResolvedValue(null), + findAndCountAll: vi.fn().mockResolvedValue({ count: 0, rows: [] }), + create: vi.fn().mockResolvedValue({ cpayId: 1 }), + }, + ApiKey: {}, ApiMaster: {}, +})); + +let app; + +beforeAll(async () => { + const router = (await import('../../app/routers/router.js')).default + || require('../../app/routers/router.js'); + app = express(); + app.use(express.json()); + app.use('/', router); +}); + +describe('CustomerPayment auth contract', () => { + test('GET 403 without authKey', async () => { expect((await request(app).get('/v1/customerpayment/1')).status).toBe(403); }); + test('POST 403 without authKey', async () => { + const res = await request(app).post('/v1/customerpayment').send({ cpayCustId: 1, cpayDate: '2026-01-01', cpayAmount: 100 }); + expect(res.status).toBe(403); + }); + test('GET /bycustomer/:id 403 without authKey', async () => { expect((await request(app).get('/v1/customerpayment/bycustomer/1')).status).toBe(403); }); + test('PATCH 403 without authKey', async () => { expect((await request(app).patch('/v1/customerpayment/1').send({ cpayAmount: 50 })).status).toBe(403); }); + test('DELETE 403 without authKey', async () => { expect((await request(app).delete('/v1/customerpayment/1')).status).toBe(403); }); +}); + +describe('CustomerPayment route mounting', () => { + test('routes mounted', async () => { + expect((await request(app).get('/v1/customerpayment/1').set('authKey', 'any')).status).not.toBe(404); + }); +}); + +describe('CustomerPayment body validation', () => { + test('POST rejects unknown field', async () => { + const res = await request(app).post('/v1/customerpayment').set('authKey', 'any').send({ cpayCustId: 1, cpayDate: '2026-01-01', cpayAmount: 100, bogus: 'no' }); + expect(res.status).toBe(400); + }); + test('POST rejects missing cpayAmount', async () => { + const res = await request(app).post('/v1/customerpayment').set('authKey', 'any').send({ cpayCustId: 1, cpayDate: '2026-01-01' }); + expect(res.status).toBe(400); + }); +}); diff --git a/tests/api/inventoryitem.test.js b/tests/api/inventoryitem.test.js new file mode 100644 index 0000000..f0be680 --- /dev/null +++ b/tests/api/inventoryitem.test.js @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark + +import { describe, test, expect, vi, beforeAll } from 'vitest'; +import request from 'supertest'; +import express from 'express'; + +vi.mock('../../app/config/db.config.js', () => ({ + sequelize: { + query: vi.fn().mockResolvedValue([]), + QueryTypes: { SELECT: 'SELECT' }, + }, + Sequelize: {}, + Customer: {}, TimeEntry: {}, Worker: {}, BillingType: {}, + InventoryItem: { + findByPk: vi.fn().mockResolvedValue(null), + findAndCountAll: vi.fn().mockResolvedValue({ count: 0, rows: [] }), + create: vi.fn().mockResolvedValue({ invitId: 1 }), + }, + Company: {}, + ApiKey: {}, ApiMaster: {}, +})); + +let app; + +beforeAll(async () => { + const router = (await import('../../app/routers/router.js')).default + || require('../../app/routers/router.js'); + app = express(); + app.use(express.json()); + app.use('/', router); +}); + +describe('InventoryItem auth contract', () => { + test('GET /v1/inventoryitem/:id returns 403 when authKey missing', async () => { + expect((await request(app).get('/v1/inventoryitem/1')).status).toBe(403); + }); + test('POST /v1/inventoryitem returns 403 when authKey missing', async () => { + const res = await request(app) + .post('/v1/inventoryitem') + .send({ invitDescription: 'Widget', invitQty: 10 }); + expect(res.status).toBe(403); + }); + test('GET /v1/inventoryitem/bycompany/:id returns 403 when authKey missing', async () => { + expect((await request(app).get('/v1/inventoryitem/bycompany/1')).status).toBe(403); + }); + test('PATCH /v1/inventoryitem/:id returns 403 when authKey missing', async () => { + expect((await request(app).patch('/v1/inventoryitem/1').send({ invitQty: 5 })).status).toBe(403); + }); + test('DELETE /v1/inventoryitem/:id returns 403 when authKey missing', async () => { + expect((await request(app).delete('/v1/inventoryitem/1')).status).toBe(403); + }); +}); + +describe('InventoryItem route mounting', () => { + test('routes mounted (not 404)', async () => { + const res = await request(app).get('/v1/inventoryitem/1').set('authKey', 'any'); + expect(res.status).not.toBe(404); + }); +}); + +describe('InventoryItem body validation', () => { + test('POST rejects unknown field with 400', async () => { + const res = await request(app) + .post('/v1/inventoryitem') + .set('authKey', 'any') + .send({ invitDescription: 'X', invitQty: 1, bogus: 'no' }); + expect(res.status).toBe(400); + }); + test('POST rejects missing required invitQty with 400', async () => { + const res = await request(app) + .post('/v1/inventoryitem') + .set('authKey', 'any') + .send({ invitDescription: 'X' }); + expect(res.status).toBe(400); + }); +}); diff --git a/tests/api/invoice.test.js b/tests/api/invoice.test.js new file mode 100644 index 0000000..c4b1fd9 --- /dev/null +++ b/tests/api/invoice.test.js @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark + +import { describe, test, expect, vi, beforeAll } from 'vitest'; +import request from 'supertest'; +import express from 'express'; + +vi.mock('../../app/config/db.config.js', () => ({ + sequelize: { query: vi.fn().mockResolvedValue([]), QueryTypes: { SELECT: 'SELECT' } }, + Sequelize: {}, + Customer: {}, TimeEntry: {}, Worker: {}, BillingType: {}, InventoryItem: {}, Company: {}, Job: {}, + Invoice: { + findByPk: vi.fn().mockResolvedValue(null), + findAndCountAll: vi.fn().mockResolvedValue({ count: 0, rows: [] }), + create: vi.fn().mockResolvedValue({ invId: 1 }), + }, + CustomerPayment: {}, + ApiKey: {}, ApiMaster: {}, +})); + +let app; + +beforeAll(async () => { + const router = (await import('../../app/routers/router.js')).default + || require('../../app/routers/router.js'); + app = express(); + app.use(express.json()); + app.use('/', router); +}); + +describe('Invoice auth contract', () => { + test('GET 403 without authKey', async () => { expect((await request(app).get('/v1/invoice/1')).status).toBe(403); }); + test('POST 403 without authKey', async () => { + const res = await request(app).post('/v1/invoice').send({ invCustId: 1, invDate: '2026-01-01', invDueDate: '2026-02-01' }); + expect(res.status).toBe(403); + }); + test('GET /bycustomer/:id 403 without authKey', async () => { expect((await request(app).get('/v1/invoice/bycustomer/1')).status).toBe(403); }); + test('PATCH 403 without authKey', async () => { expect((await request(app).patch('/v1/invoice/1').send({ invPaid: true })).status).toBe(403); }); + test('DELETE 403 without authKey', async () => { expect((await request(app).delete('/v1/invoice/1')).status).toBe(403); }); +}); + +describe('Invoice route mounting', () => { + test('routes mounted', async () => { + expect((await request(app).get('/v1/invoice/1').set('authKey', 'any')).status).not.toBe(404); + }); +}); + +describe('Invoice body validation', () => { + test('POST rejects unknown field', async () => { + const res = await request(app).post('/v1/invoice').set('authKey', 'any').send({ invCustId: 1, invDate: '2026-01-01', invDueDate: '2026-02-01', bogus: 'no' }); + expect(res.status).toBe(400); + }); + test('POST rejects bad date format', async () => { + const res = await request(app).post('/v1/invoice').set('authKey', 'any').send({ invCustId: 1, invDate: 'tomorrow', invDueDate: '2026-02-01' }); + expect(res.status).toBe(400); + }); +}); diff --git a/tests/api/invoicejob.test.js b/tests/api/invoicejob.test.js new file mode 100644 index 0000000..ae6cfe9 --- /dev/null +++ b/tests/api/invoicejob.test.js @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark + +import { describe, test, expect, vi, beforeAll } from 'vitest'; +import request from 'supertest'; +import express from 'express'; + +vi.mock('../../app/config/db.config.js', () => ({ + sequelize: { query: vi.fn().mockResolvedValue([]), QueryTypes: { SELECT: 'SELECT' } }, + Sequelize: {}, + Customer: {}, TimeEntry: {}, Worker: {}, BillingType: {}, InventoryItem: {}, Company: {}, + Job: {}, Invoice: {}, CustomerPayment: {}, + InvoiceJob: { + findByPk: vi.fn().mockResolvedValue(null), + findOne: vi.fn().mockResolvedValue(null), + findAndCountAll: vi.fn().mockResolvedValue({ count: 0, rows: [] }), + create: vi.fn().mockResolvedValue({ injbId: 1 }), + }, + ProductEntry: {}, + ApiKey: {}, ApiMaster: {}, +})); + +let app; + +beforeAll(async () => { + const router = (await import('../../app/routers/router.js')).default + || require('../../app/routers/router.js'); + app = express(); + app.use(express.json()); + app.use('/', router); +}); + +describe('InvoiceJob auth contract', () => { + test('GET 403 without authKey', async () => { expect((await request(app).get('/v1/invoicejob/1')).status).toBe(403); }); + test('POST 403 without authKey', async () => { + const res = await request(app).post('/v1/invoicejob').send({ injbInvId: 1, injbJobId: 1, injbAmount: 100 }); + expect(res.status).toBe(403); + }); + test('GET /byinvoice/:id 403 without authKey', async () => { expect((await request(app).get('/v1/invoicejob/byinvoice/1')).status).toBe(403); }); + test('PATCH 403 without authKey', async () => { expect((await request(app).patch('/v1/invoicejob/1').send({ injbAmount: 50 })).status).toBe(403); }); + test('DELETE 403 without authKey', async () => { expect((await request(app).delete('/v1/invoicejob/1')).status).toBe(403); }); +}); + +describe('InvoiceJob route mounting', () => { + test('routes mounted', async () => { + expect((await request(app).get('/v1/invoicejob/1').set('authKey', 'any')).status).not.toBe(404); + }); +}); + +describe('InvoiceJob body validation', () => { + test('POST rejects unknown field', async () => { + const res = await request(app).post('/v1/invoicejob').set('authKey', 'any').send({ injbInvId: 1, injbJobId: 1, injbAmount: 1, bogus: 'no' }); + expect(res.status).toBe(400); + }); +}); diff --git a/tests/api/job.test.js b/tests/api/job.test.js new file mode 100644 index 0000000..3cbd9a7 --- /dev/null +++ b/tests/api/job.test.js @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark + +import { describe, test, expect, vi, beforeAll } from 'vitest'; +import request from 'supertest'; +import express from 'express'; + +vi.mock('../../app/config/db.config.js', () => ({ + sequelize: { query: vi.fn().mockResolvedValue([]), QueryTypes: { SELECT: 'SELECT' } }, + Sequelize: {}, + Customer: {}, TimeEntry: {}, Worker: {}, BillingType: {}, InventoryItem: {}, Company: {}, + Job: { + findByPk: vi.fn().mockResolvedValue(null), + findAndCountAll: vi.fn().mockResolvedValue({ count: 0, rows: [] }), + create: vi.fn().mockResolvedValue({ jobId: 1 }), + }, + Invoice: {}, CustomerPayment: {}, + ApiKey: {}, ApiMaster: {}, +})); + +let app; + +beforeAll(async () => { + const router = (await import('../../app/routers/router.js')).default + || require('../../app/routers/router.js'); + app = express(); + app.use(express.json()); + app.use('/', router); +}); + +describe('Job auth contract', () => { + test('GET /v1/job/:id 403 without authKey', async () => { expect((await request(app).get('/v1/job/1')).status).toBe(403); }); + test('POST /v1/job 403 without authKey', async () => { + const res = await request(app).post('/v1/job').send({ jobCustId: 1, jobDesc: 'work' }); + expect(res.status).toBe(403); + }); + test('GET /v1/job/bycustomer/:id 403 without authKey', async () => { expect((await request(app).get('/v1/job/bycustomer/1')).status).toBe(403); }); + test('PATCH /v1/job/:id 403 without authKey', async () => { expect((await request(app).patch('/v1/job/1').send({ jobDesc: 'x' })).status).toBe(403); }); + test('DELETE /v1/job/:id 403 without authKey', async () => { expect((await request(app).delete('/v1/job/1')).status).toBe(403); }); +}); + +describe('Job route mounting', () => { + test('routes mounted', async () => { + expect((await request(app).get('/v1/job/1').set('authKey', 'any')).status).not.toBe(404); + }); +}); + +describe('Job body validation', () => { + test('POST rejects unknown field', async () => { + const res = await request(app).post('/v1/job').set('authKey', 'any').send({ jobCustId: 1, jobDesc: 'x', bogus: 'no' }); + expect(res.status).toBe(400); + }); + test('POST rejects missing jobDesc', async () => { + const res = await request(app).post('/v1/job').set('authKey', 'any').send({ jobCustId: 1 }); + expect(res.status).toBe(400); + }); +}); diff --git a/tests/api/productentry.test.js b/tests/api/productentry.test.js new file mode 100644 index 0000000..3cd5a02 --- /dev/null +++ b/tests/api/productentry.test.js @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark + +import { describe, test, expect, vi, beforeAll } from 'vitest'; +import request from 'supertest'; +import express from 'express'; + +vi.mock('../../app/config/db.config.js', () => ({ + sequelize: { query: vi.fn().mockResolvedValue([]), QueryTypes: { SELECT: 'SELECT' } }, + Sequelize: {}, + Customer: {}, TimeEntry: {}, Worker: {}, BillingType: {}, InventoryItem: {}, Company: {}, + Job: {}, Invoice: {}, CustomerPayment: {}, InvoiceJob: {}, + ProductEntry: { + findByPk: vi.fn().mockResolvedValue(null), + findAndCountAll: vi.fn().mockResolvedValue({ count: 0, rows: [] }), + create: vi.fn().mockResolvedValue({ pentId: 1 }), + }, + ApiKey: {}, ApiMaster: {}, +})); + +let app; + +beforeAll(async () => { + const router = (await import('../../app/routers/router.js')).default + || require('../../app/routers/router.js'); + app = express(); + app.use(express.json()); + app.use('/', router); +}); + +describe('ProductEntry auth contract', () => { + test('GET 403 without authKey', async () => { expect((await request(app).get('/v1/productentry/1')).status).toBe(403); }); + test('POST 403 without authKey', async () => { + const res = await request(app).post('/v1/productentry').send({ pentQty: 1, pentJobId: 1, pentInvtId: 1 }); + expect(res.status).toBe(403); + }); + test('GET /byjob/:id 403 without authKey', async () => { expect((await request(app).get('/v1/productentry/byjob/1')).status).toBe(403); }); + test('PATCH 403 without authKey', async () => { expect((await request(app).patch('/v1/productentry/1').send({ pentQty: 2 })).status).toBe(403); }); + test('DELETE 403 without authKey', async () => { expect((await request(app).delete('/v1/productentry/1')).status).toBe(403); }); +}); + +describe('ProductEntry route mounting', () => { + test('routes mounted', async () => { + expect((await request(app).get('/v1/productentry/1').set('authKey', 'any')).status).not.toBe(404); + }); +}); + +describe('ProductEntry body validation', () => { + test('POST rejects unknown field', async () => { + const res = await request(app).post('/v1/productentry').set('authKey', 'any').send({ pentQty: 1, pentJobId: 1, pentInvtId: 1, bogus: 'no' }); + expect(res.status).toBe(400); + }); + test('POST rejects missing pentJobId', async () => { + const res = await request(app).post('/v1/productentry').set('authKey', 'any').send({ pentQty: 1, pentInvtId: 1 }); + expect(res.status).toBe(400); + }); +}); diff --git a/tests/api/versioninfo.test.js b/tests/api/versioninfo.test.js new file mode 100644 index 0000000..f4c237f --- /dev/null +++ b/tests/api/versioninfo.test.js @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark + +import { describe, test, expect, vi, beforeAll } from 'vitest'; +import request from 'supertest'; +import express from 'express'; + +vi.mock('../../app/config/db.config.js', () => ({ + sequelize: { query: vi.fn().mockResolvedValue([]), QueryTypes: { SELECT: 'SELECT' } }, + Sequelize: {}, + Customer: {}, TimeEntry: {}, Worker: {}, BillingType: {}, InventoryItem: {}, Company: {}, + Job: {}, Invoice: {}, CustomerPayment: {}, InvoiceJob: {}, ProductEntry: {}, + VersionInfo: { + findByPk: vi.fn().mockResolvedValue(null), + findAndCountAll: vi.fn().mockResolvedValue({ count: 0, rows: [] }), + create: vi.fn().mockResolvedValue({ viId: 1 }), + }, + ApiKey: {}, ApiMaster: {}, +})); + +let app; + +beforeAll(async () => { + const router = (await import('../../app/routers/router.js')).default + || require('../../app/routers/router.js'); + app = express(); + app.use(express.json()); + app.use('/', router); +}); + +describe('VersionInfo auth contract', () => { + test('GET 403 without authKey', async () => { expect((await request(app).get('/v1/versioninfo/1')).status).toBe(403); }); + test('GET list 403 without authKey', async () => { expect((await request(app).get('/v1/versioninfo')).status).toBe(403); }); + test('POST 403 for non-master key', async () => { + const res = await request(app) + .post('/v1/versioninfo') + .set('authKey', 'not-master') + .send({ viVersion: '1.2.3', viDate: '2026-01-01T00:00:00Z' }); + expect(res.status).toBe(403); + expect(res.body.message).toMatch(/master/i); + }); + test('PATCH 403 for non-master key', async () => { + const res = await request(app) + .patch('/v1/versioninfo/1') + .set('authKey', 'not-master') + .send({ viVersion: '1.2.4' }); + expect(res.status).toBe(403); + }); + test('DELETE 403 for non-master key', async () => { + expect((await request(app).delete('/v1/versioninfo/1').set('authKey', 'not-master')).status).toBe(403); + }); +}); + +describe('VersionInfo route mounting', () => { + test('GET /v1/versioninfo/:id mounted', async () => { + expect((await request(app).get('/v1/versioninfo/1').set('authKey', 'any')).status).not.toBe(404); + }); + test('GET /v1/versioninfo mounted', async () => { + expect((await request(app).get('/v1/versioninfo').set('authKey', 'any')).status).not.toBe(404); + }); +}); + +describe('VersionInfo body validation', () => { + test('POST rejects unknown field', async () => { + const res = await request(app) + .post('/v1/versioninfo') + .set('authKey', 'any') + .send({ viVersion: '1.0.0', viDate: '2026-01-01T00:00:00Z', bogus: 'no' }); + expect(res.status).toBe(400); + }); + test('POST rejects bad datetime', async () => { + const res = await request(app) + .post('/v1/versioninfo') + .set('authKey', 'any') + .send({ viVersion: '1.0.0', viDate: 'now' }); + expect(res.status).toBe(400); + }); +}); diff --git a/tests/api/worker.test.js b/tests/api/worker.test.js new file mode 100644 index 0000000..c5ecefc --- /dev/null +++ b/tests/api/worker.test.js @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark +// +// HTTP smoke tests for /v1/worker/*. Same approach as customer.test.js: +// asserts only on auth-contract behavior and route mounting; integration +// tests against a live Postgres cover the success paths. + +import { describe, test, expect, vi, beforeAll } from 'vitest'; +import request from 'supertest'; +import express from 'express'; + +vi.mock('../../app/config/db.config.js', () => ({ + sequelize: { + query: vi.fn().mockResolvedValue([]), + QueryTypes: { SELECT: 'SELECT' }, + }, + Sequelize: {}, + Customer: {}, + TimeEntry: {}, + Worker: { + findByPk: vi.fn().mockResolvedValue(null), + findAll: vi.fn().mockResolvedValue([]), + findAndCountAll: vi.fn().mockResolvedValue({ count: 0, rows: [] }), + create: vi.fn().mockResolvedValue({ workerId: 1 }), + }, + ApiKey: {}, + ApiMaster: {}, +})); + +let app; + +beforeAll(async () => { + const router = (await import('../../app/routers/router.js')).default + || require('../../app/routers/router.js'); + app = express(); + app.use(express.json()); + app.use('/', router); +}); + +describe('Worker auth contract', () => { + test('GET /v1/worker/:id returns 403 when authKey missing', async () => { + const res = await request(app).get('/v1/worker/1'); + expect(res.status).toBe(403); + expect(res.body.message).toMatch(/Authorization key not sent/i); + }); + + test('POST /v1/worker returns 403 when authKey missing', async () => { + const res = await request(app) + .post('/v1/worker') + .send({ + workerFName: 'Ada', + workerLName: 'Lovelace', + workerTitle: 'Analyst', + workerDefaultBillType: 1, + }); + expect(res.status).toBe(403); + }); + + test('GET /v1/worker/bycompany/:id returns 403 when authKey missing', async () => { + const res = await request(app).get('/v1/worker/bycompany/1'); + expect(res.status).toBe(403); + }); + + test('PATCH /v1/worker/:id returns 403 when authKey missing', async () => { + const res = await request(app) + .patch('/v1/worker/1') + .send({ workerTitle: 'Senior' }); + expect(res.status).toBe(403); + }); + + test('DELETE /v1/worker/:id returns 403 when authKey missing', async () => { + const res = await request(app).delete('/v1/worker/1'); + expect(res.status).toBe(403); + }); +}); + +describe('Worker route mounting (regression)', () => { + test('GET /v1/worker/:id is mounted (not 404)', async () => { + const res = await request(app) + .get('/v1/worker/1') + .set('authKey', 'any'); + expect(res.status).not.toBe(404); + }); + + test('POST /v1/worker is mounted', async () => { + const res = await request(app) + .post('/v1/worker') + .set('authKey', 'any') + .send({ + workerFName: 'Ada', + workerLName: 'Lovelace', + workerTitle: 'Analyst', + workerDefaultBillType: 1, + workerCompId: 1, + }); + expect(res.status).not.toBe(404); + }); + + test('controller exits with a single well-formed response', async () => { + const res = await request(app) + .get('/v1/worker/1') + .set('authKey', 'whatever-key'); + expect(typeof res.status).toBe('number'); + expect(res.status).toBeGreaterThanOrEqual(200); + expect(res.status).toBeLessThan(600); + expect(res.body).toBeTypeOf('object'); + expect(res.body.message).toBeDefined(); + }); +}); + +describe('Worker body validation', () => { + test('POST /v1/worker rejects unknown body field with 400', async () => { + const res = await request(app) + .post('/v1/worker') + .set('authKey', 'any') + .send({ + workerFName: 'Ada', + workerLName: 'Lovelace', + workerTitle: 'Analyst', + workerDefaultBillType: 1, + bogusField: 'reject me', + }); + expect(res.status).toBe(400); + }); + + test('POST /v1/worker rejects missing required field with 400', async () => { + const res = await request(app) + .post('/v1/worker') + .set('authKey', 'any') + .send({ + workerFName: 'Ada', + // missing workerLName, workerTitle, workerDefaultBillType + }); + expect(res.status).toBe(400); + }); + + test('PATCH /v1/worker/:id rejects unknown body field with 400', async () => { + const res = await request(app) + .patch('/v1/worker/1') + .set('authKey', 'any') + .send({ bogusField: 'no' }); + expect(res.status).toBe(400); + }); +});