Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions app/config/openapi.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
124 changes: 124 additions & 0 deletions app/controllers/timeentrycontroller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
5 changes: 5 additions & 0 deletions app/routers/router.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
17 changes: 17 additions & 0 deletions app/schemas/timeentry.schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -63,4 +79,5 @@ module.exports = {
createTimeEntryBody,
updateTimeEntryBody,
listByCompanyQuery,
exportCsvQuery,
};
92 changes: 92 additions & 0 deletions tests/api/timeentry-export-csv.test.js
Original file line number Diff line number Diff line change
@@ -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();
}
});
});
Loading