diff --git a/app/config/openapi.js b/app/config/openapi.js index f044b01..66abdc8 100644 --- a/app/config/openapi.js +++ b/app/config/openapi.js @@ -529,6 +529,30 @@ const spec = { responses: { 200: { description: 'Archived' }, 404: { description: 'Not found' }, 403: { description: 'Auth failure' } }, }, }, + '/v1/timeentry/export.csv': { + get: { + summary: 'CSV export of time entries (invoicing-friendly)', + description: + 'text/csv response with attachment Content-Disposition. ' + + 'Same filter set as bycompany (customerId, from, to) plus a ' + + 'master-only `companyId` requirement. 5000-row hard cap; ' + + 'oversize results append `# truncated…` comment row.', + security: [{ authKey: [] }], + parameters: [ + { name: 'companyId', in: 'query', schema: { type: 'integer' }, description: 'Required for master keys.' }, + { name: 'customerId', in: 'query', schema: { type: 'integer' } }, + { name: 'from', in: 'query', schema: { type: 'string', format: 'date-time' } }, + { name: 'to', in: 'query', schema: { type: 'string', format: 'date-time' } }, + { name: 'limit', in: 'query', schema: { type: 'integer', default: 5000, maximum: 5000 } }, + { name: 'offset', in: 'query', schema: { type: 'integer', default: 0 } }, + ], + responses: { + 200: { description: 'CSV body', content: { 'text/csv': { schema: { type: 'string' } } } }, + 400: { description: 'Master without companyId' }, + 403: { description: 'Missing authKey or cross-tenant export attempt' }, + }, + }, + }, '/v1/timeentry/bycompany/{id}': { get: { summary: 'List time entries for a company', diff --git a/app/controllers/timeentrycontroller.js b/app/controllers/timeentrycontroller.js index b70a813..f4f53e0 100644 --- a/app/controllers/timeentrycontroller.js +++ b/app/controllers/timeentrycontroller.js @@ -272,5 +272,129 @@ exports.remove = async (req, res) => { } }; +/** + * GET /v1/timeentry/export.csv?companyId=&customerId=&from=&to= + * + * CSV dump of time entries. The natural invoicing flow: + * - filter by customer + date range + * - export rows + * - feed into spreadsheet / accounting tool + * + * Auth shape mirrors /v1/customer/export.csv: master must specify + * companyId, non-master is auto-scoped. Same 5000-row cap with the + * trailing `# truncated` comment if exceeded. + * + * Date range is permissive on bad input (silent drop) to match the + * existing listByCompany behavior — a typo'd `from` query param + * shouldn't 400 a long-running export script. + */ +exports.exportCsv = async (req, res) => { + const authKey = req.get('authKey'); + if (!authKey) { + return res.status(403).json({ message: "Authorization key not sent." }); + } + + let isMasterKey; + try { + isMasterKey = await IsMaster(authKey); + } catch (error) { + log.error({ err: error }, 'IsMaster failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } + + let effectiveCompanyId; + if (isMasterKey) { + const qCompanyId = Number(req.query.companyId); + if (!Number.isInteger(qCompanyId) || qCompanyId <= 0) { + return res.status(400).json({ + message: "Master keys must specify companyId on export.csv.", + }); + } + effectiveCompanyId = qCompanyId; + } else { + 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." }); + } + const qCompanyId = req.query.companyId !== undefined ? Number(req.query.companyId) : null; + if (qCompanyId !== null && qCompanyId !== authKeyCompanyId) { + return res.status(403).json({ + message: "Cannot export time entries for a company you do not belong to.", + }); + } + effectiveCompanyId = authKeyCompanyId; + } + + const where = { teCompId: effectiveCompanyId, teArch: false }; + const customerId = Number(req.query.customerId); + if (Number.isInteger(customerId) && customerId > 0) { + where.teCustId = customerId; + } + const Op = db.Sequelize && db.Sequelize.Op; + if (Op && req.query.from) { + where.teStartedAt = Object.assign(where.teStartedAt || {}, { [Op.gte]: req.query.from }); + } + if (Op && req.query.to) { + where.teStartedAt = Object.assign(where.teStartedAt || {}, { [Op.lte]: req.query.to }); + } + + const HARD_CAP = 5000; + const requestedLimit = parseInt(req.query.limit, 10); + const limit = Number.isInteger(requestedLimit) && requestedLimit > 0 + ? Math.min(requestedLimit, HARD_CAP) + : HARD_CAP; + const requestedOffset = parseInt(req.query.offset, 10); + const offset = Number.isInteger(requestedOffset) && requestedOffset >= 0 + ? requestedOffset + : 0; + + let rows; + try { + rows = await TimeEntry.findAll({ + where, + limit: limit + 1, + offset, + order: [['teStartedAt', 'DESC']], + }); + } catch (error) { + log.error({ err: error }, 'TimeEntry.findAll for CSV export failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } + + const truncated = rows.length > limit; + if (truncated) rows = rows.slice(0, limit); + + const FIELDS = [ + 'teId', 'teCustId', 'teCompId', + 'teStartedAt', 'teEndedAt', 'teMinutes', + 'teBillable', 'teDescription', + ]; + const escape = (val) => { + if (val === null || val === undefined) return '""'; + // Date instances serialize to ISO; everything else .toString(). + const s = (val instanceof Date) ? val.toISOString() : String(val); + return '"' + s.replace(/"/g, '""') + '"'; + }; + const lines = []; + lines.push(FIELDS.join(',')); + for (const r of rows) { + lines.push(FIELDS.map((f) => escape(r[f])).join(',')); + } + if (truncated) { + lines.push(`# truncated at ${limit} rows; re-call with offset=${offset + limit} to continue`); + } + const body = lines.join('\r\n') + '\r\n'; + res.setHeader('Content-Type', 'text/csv; charset=utf-8'); + res.setHeader('Content-Disposition', + `attachment; filename="timeentries-company-${effectiveCompanyId}.csv"`); + return res.status(200).send(body); +}; + // Exposed for unit testing. exports._internals = { computeMinutes, IsMaster, GetCompanyId }; diff --git a/app/routers/router.js b/app/routers/router.js index 272003b..211087b 100644 --- a/app/routers/router.js +++ b/app/routers/router.js @@ -123,6 +123,11 @@ router.delete( v.params(timeEntrySchemas.intIdParam), timeEntry.remove, ); +router.get( + '/v1/timeentry/export.csv', + v.query(timeEntrySchemas.exportCsvQuery), + timeEntry.exportCsv, +); // v1 worker routes. router.post( diff --git a/app/schemas/timeentry.schema.js b/app/schemas/timeentry.schema.js index 477ff36..ab041a5 100644 --- a/app/schemas/timeentry.schema.js +++ b/app/schemas/timeentry.schema.js @@ -48,6 +48,22 @@ const updateTimeEntryBody = z.object({ message: 'Unexpected field in body. Whitelist: teDescription, teStartedAt, teEndedAt, teBillable.', }); +/** + * GET /v1/timeentry/export.csv query schema. Like listByCompanyQuery + * but adds companyId (required for master) and bumps the limit cap + * to 5000 to match the CSV body's hard cap. + */ +const exportCsvQuery = z.object({ + companyId: z.coerce.number().int().positive().optional(), + customerId: z.coerce.number().int().positive().optional(), + from: isoDatetime.optional(), + to: isoDatetime.optional(), + limit: z.coerce.number().int().positive().max(5000).optional(), + offset: z.coerce.number().int().nonnegative().optional(), +}).strict({ + message: 'Unexpected query parameter. Allowed: companyId, customerId, from, to, limit, offset.', +}); + const listByCompanyQuery = z.object({ customerId: z.coerce.number().int().positive().optional(), from: isoDatetime.optional(), @@ -63,4 +79,5 @@ module.exports = { createTimeEntryBody, updateTimeEntryBody, listByCompanyQuery, + exportCsvQuery, }; diff --git a/tests/api/timeentry-export-csv.test.js b/tests/api/timeentry-export-csv.test.js new file mode 100644 index 0000000..2cbbbaa --- /dev/null +++ b/tests/api/timeentry-export-csv.test.js @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark +// +// HTTP tests for GET /v1/timeentry/export.csv. + +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: { Op: {} }, + Customer: {}, TimeEntry: { + findAll: vi.fn().mockResolvedValue([]), + findByPk: vi.fn(), + create: vi.fn(), + }, + Worker: {}, BillingType: {}, InventoryItem: {}, + Company: {}, Job: {}, Invoice: {}, CustomerPayment: {}, + InvoiceJob: {}, ProductEntry: {}, VersionInfo: {}, + PurchaseOrderVendor: {}, PurchaseOrderHeader: {}, PurchaseOrderLine: {}, + InventoryTransaction: {}, + 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('GET /v1/timeentry/export.csv auth contract', () => { + test('returns 403 when authKey header is missing', async () => { + const res = await request(app).get('/v1/timeentry/export.csv'); + expect(res.status).toBe(403); + }); +}); + +describe('GET /v1/timeentry/export.csv query validation', () => { + test('unknown query param is rejected', async () => { + const res = await request(app) + .get('/v1/timeentry/export.csv?bogus=1') + .set('authKey', 'any'); + expect(res.status).toBe(400); + }); + + test('limit > 5000 rejected', async () => { + const res = await request(app) + .get('/v1/timeentry/export.csv?limit=10000') + .set('authKey', 'any'); + expect(res.status).toBe(400); + }); + + test('bad ISO datetime in from rejected', async () => { + const res = await request(app) + .get('/v1/timeentry/export.csv?from=tomorrow') + .set('authKey', 'any'); + expect(res.status).toBe(400); + }); + + test('well-formed query passes schema', async () => { + const res = await request(app) + .get('/v1/timeentry/export.csv?customerId=1&from=2026-01-01T00:00:00Z&to=2026-02-01T00:00:00Z&limit=100') + .set('authKey', 'any'); + // Past zod gate; auth-fallback may return whatever, just not 400 from schema. + // (Could be 403 or 500 from DB-broken path.) + expect(res.status).not.toBe(404); + }); +}); + +describe('GET /v1/timeentry/export.csv route mounting', () => { + test('route is mounted (not treated as /:id)', async () => { + const res = await request(app) + .get('/v1/timeentry/export.csv') + .set('authKey', 'any'); + // Either CSV (success) or JSON error from handler — both prove + // the dedicated handler ran, not the :id route's intIdParam. + if (res.headers['content-type']?.includes('text/csv')) { + // success path — fine + } else { + expect(res.body).toBeTypeOf('object'); + expect(res.body.message).toBeDefined(); + } + }); +});