Skip to content

Commit 424316d

Browse files
author
CryptoJones
committed
feat: time-entry CRUD endpoints (the headline TimeTracker feature) (#9)
1 parent cb0fda1 commit 424316d

7 files changed

Lines changed: 583 additions & 1 deletion

File tree

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ Working example at [node.timetrackerapi.com](http://node.timetrackerapi.com).
2222
| `GET /v1/customer/:id` | yes (`authKey`) | Single customer lookup. Master key sees all; non-master only sees customers in its own company. |
2323
| `GET /v1/customer/bycompany/:id` | yes (`authKey`) | All customers in a company. Master sees any; non-master only its own. |
2424
| `POST /v1/customer` | yes (`authKey`) | Create a customer. Master key may target any `custCompId`; non-master keys can only create within their own company (and `custCompId` defaults to that). Returns 201 + the created customer. |
25+
| `POST /v1/timeentry` | yes (`authKey`) | Create a time entry. Body: `teCustId` (required), `teStartedAt` (required, ISO 8601), `teEndedAt` (optional — in-flight entries allowed), `teDescription`, `teBillable` (default true). `teMinutes` is computed server-side on close. |
26+
| `GET /v1/timeentry/:id` | yes (`authKey`) | Single time entry lookup. Company-scoped. Archived (soft-deleted) entries return 404. |
27+
| `GET /v1/timeentry/bycompany/:id` | yes (`authKey`) | List time entries for a company. Query params: `customerId` (filter), `from` / `to` (ISO 8601 date range on `teStartedAt`), `limit` (default 100, max 500). Ordered most-recent first. |
28+
| `PATCH /v1/timeentry/:id` | yes (`authKey`) | Partial update. Updatable: `teDescription`, `teStartedAt`, `teEndedAt`, `teBillable`. `teMinutes` is recomputed on bound change. |
29+
| `DELETE /v1/timeentry/:id` | yes (`authKey`) | Soft-delete (sets `teArch = true`). Entries are never physically removed via the API. |
2530

2631
Every v1 request must include the API key in the `authKey` HTTP header.
2732
The `/healthz` endpoint is intentionally unauthenticated so it can be
@@ -58,6 +63,7 @@ CREATE USER timetracker WITH PASSWORD 'change-me-strong-password';
5863
CREATE DATABASE timetracker WITH OWNER timetracker;
5964
SQL
6065
sudo -u postgres psql -d timetracker -f setup/TimeTracker.sql
66+
sudo -u postgres psql -d timetracker -f setup/TimeEntry.sql
6167

6268
# 4. Configure environment
6369
cp .env.example .env

app/config/db.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,5 +22,6 @@ db.sequelize = sequelize;
2222
db.Customer = require('../models/customer.model.js')(sequelize, Sequelize);
2323
db.ApiMaster = require('../models/apimaster.model.js')(sequelize, Sequelize);
2424
db.ApiKey = require('../models/apikey.model.js')(sequelize, Sequelize);
25+
db.TimeEntry = require('../models/timeentry.model.js')(sequelize, Sequelize);
2526

2627
module.exports = db;
Lines changed: 311 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,311 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// Copyright 2026 Aaron K. Clark
3+
"use strict";
4+
5+
const { sequelize } = require('../config/db.config.js');
6+
const db = require('../config/db.config.js');
7+
const log = require('../config/logger.js');
8+
const TimeEntry = db.TimeEntry;
9+
10+
/**
11+
* Auth helpers are intentionally duplicated from customercontroller.js
12+
* rather than extracted into a shared module. The duplication is
13+
* minor (about 40 lines) and the alternative — a shared auth module
14+
* — would couple the time-entry endpoints to whatever ad-hoc shape
15+
* the customer endpoints' helpers grow into. Once the auth helpers
16+
* stabilize across both controllers, we can promote them to
17+
* app/middleware/auth.js as a single source of truth.
18+
*/
19+
20+
async function IsMaster(authKeyString) {
21+
if (!authKeyString || authKeyString.length === 0) return false;
22+
try {
23+
const r = await db.sequelize.query(
24+
'SELECT * FROM "dbo"."ApiMaster" WHERE "amKEY" = ? AND "ApiMaster"."amArchive" = false;',
25+
{ replacements: [authKeyString], type: sequelize.QueryTypes.SELECT },
26+
);
27+
if (!r || r.length === 0) return false;
28+
return typeof r[0].amId === 'number' && r[0].amId > 0;
29+
} catch (error) {
30+
log.error({ err: error }, 'IsMaster query failed');
31+
return false;
32+
}
33+
}
34+
35+
async function GetCompanyId(authKeyString) {
36+
if (!authKeyString || authKeyString.length === 0) return -1;
37+
try {
38+
const r = await db.sequelize.query(
39+
'SELECT * FROM "dbo"."ApiKey" WHERE "akKEY" = ? AND "ApiKey"."akArchive" = false;',
40+
{ replacements: [authKeyString], type: sequelize.QueryTypes.SELECT },
41+
);
42+
if (!r || r.length === 0) return -1;
43+
const cid = r[0].akCompanyId;
44+
return typeof cid === 'number' && cid > 0 ? cid : -1;
45+
} catch (error) {
46+
log.error({ err: error }, 'GetCompanyId query failed');
47+
return -1;
48+
}
49+
}
50+
51+
const ALLOWED_FIELDS_CREATE = [
52+
'teCustId', 'teDescription', 'teStartedAt', 'teEndedAt',
53+
'teBillable',
54+
];
55+
const ALLOWED_FIELDS_UPDATE = [
56+
'teDescription', 'teStartedAt', 'teEndedAt', 'teBillable',
57+
];
58+
59+
function computeMinutes(startedAt, endedAt) {
60+
if (!startedAt || !endedAt) return null;
61+
const start = new Date(startedAt).getTime();
62+
const end = new Date(endedAt).getTime();
63+
if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return null;
64+
return Math.round((end - start) / 60000);
65+
}
66+
67+
/**
68+
* POST /v1/timeentry
69+
*
70+
* Create a new time entry for a customer in the auth'd company.
71+
* Master keys may set teCompId; non-master keys' entries are
72+
* scoped to their own company.
73+
*/
74+
exports.create = async (req, res) => {
75+
const authKey = req.get('authKey');
76+
if (!authKey) {
77+
return res.status(403).json({ message: "Authorization key not sent." });
78+
}
79+
80+
const isMaster = await IsMaster(authKey);
81+
let companyId;
82+
if (isMaster) {
83+
companyId = Number(req.body && req.body.teCompId);
84+
if (!Number.isInteger(companyId) || companyId <= 0) {
85+
return res.status(400).json({ message: "Master-key requests must specify teCompId." });
86+
}
87+
} else {
88+
companyId = await GetCompanyId(authKey);
89+
if (companyId === -1) {
90+
return res.status(403).json({ message: "Invalid Authorization Key." });
91+
}
92+
if (req.body && req.body.teCompId !== undefined &&
93+
Number(req.body.teCompId) !== companyId) {
94+
return res.status(403).json({
95+
message: "Cannot create a time entry for a company you do not belong to.",
96+
});
97+
}
98+
}
99+
100+
const body = req.body || {};
101+
const payload = {};
102+
for (const f of ALLOWED_FIELDS_CREATE) {
103+
if (body[f] !== undefined) payload[f] = body[f];
104+
}
105+
if (!payload.teCustId || !Number.isInteger(Number(payload.teCustId))) {
106+
return res.status(400).json({ message: "teCustId is required and must be an integer." });
107+
}
108+
if (!payload.teStartedAt) {
109+
return res.status(400).json({ message: "teStartedAt is required (ISO 8601)." });
110+
}
111+
payload.teCompId = companyId;
112+
payload.teArch = false;
113+
payload.teMinutes = computeMinutes(payload.teStartedAt, payload.teEndedAt);
114+
115+
try {
116+
const created = await TimeEntry.create(payload);
117+
return res.status(201).json({ message: "Time entry created.", timeEntry: created });
118+
} catch (error) {
119+
log.error({ err: error }, 'TimeEntry.create failed');
120+
return res.status(500).json({ message: "Error!", error: String(error) });
121+
}
122+
};
123+
124+
/**
125+
* GET /v1/timeentry/:id — fetch a single time entry by id.
126+
*
127+
* Scoped: non-master keys may only read entries in their own company.
128+
*/
129+
exports.getById = async (req, res) => {
130+
const authKey = req.get('authKey');
131+
if (!authKey) {
132+
return res.status(403).json({ message: "Authorization key not sent." });
133+
}
134+
135+
let entry;
136+
try {
137+
entry = await TimeEntry.findByPk(req.params.id);
138+
} catch (error) {
139+
log.error({ err: error }, 'TimeEntry.findByPk failed');
140+
return res.status(500).json({ message: "Error!", error: String(error) });
141+
}
142+
if (!entry || entry.teArch) {
143+
return res.status(404).json({ message: "Not found." });
144+
}
145+
146+
const isMaster = await IsMaster(authKey);
147+
if (!isMaster) {
148+
const companyId = await GetCompanyId(authKey);
149+
if (companyId === -1 || entry.teCompId !== companyId) {
150+
return res.status(403).json({ message: "Invalid Authorization Key." });
151+
}
152+
}
153+
return res.status(200).json({ message: "Found.", timeEntry: entry });
154+
};
155+
156+
/**
157+
* GET /v1/timeentry/bycompany/:id — list time entries for a company.
158+
*
159+
* Honors `?customerId=<int>` filter, `?from=<iso>` / `?to=<iso>`
160+
* date-range filter, and `?limit=<int>` (default 100, max 500).
161+
*/
162+
exports.listByCompany = async (req, res) => {
163+
const authKey = req.get('authKey');
164+
if (!authKey) {
165+
return res.status(403).json({ message: "Authorization key not sent." });
166+
}
167+
168+
const targetCompanyId = Number(req.params.id);
169+
if (!Number.isInteger(targetCompanyId) || targetCompanyId <= 0) {
170+
return res.status(400).json({ message: "Invalid company id." });
171+
}
172+
173+
const isMaster = await IsMaster(authKey);
174+
if (!isMaster) {
175+
const companyId = await GetCompanyId(authKey);
176+
if (companyId === -1 || companyId !== targetCompanyId) {
177+
return res.status(403).json({ message: "Invalid Authorization Key." });
178+
}
179+
}
180+
181+
const where = { teCompId: targetCompanyId, teArch: false };
182+
const customerId = Number(req.query.customerId);
183+
if (Number.isInteger(customerId) && customerId > 0) {
184+
where.teCustId = customerId;
185+
}
186+
// Date range — use Sequelize.Op.gte/lte. Keep it permissive: bad
187+
// dates are silently dropped rather than 400'd, so a typo in the
188+
// query string doesn't break the call.
189+
const Op = db.Sequelize && db.Sequelize.Op;
190+
if (Op && req.query.from) {
191+
where.teStartedAt = Object.assign(where.teStartedAt || {}, { [Op.gte]: req.query.from });
192+
}
193+
if (Op && req.query.to) {
194+
where.teStartedAt = Object.assign(where.teStartedAt || {}, { [Op.lte]: req.query.to });
195+
}
196+
197+
const requestedLimit = parseInt(req.query.limit, 10);
198+
const limit = Number.isInteger(requestedLimit) && requestedLimit > 0
199+
? Math.min(requestedLimit, 500)
200+
: 100;
201+
202+
try {
203+
const entries = await TimeEntry.findAll({ where, limit, order: [['teStartedAt', 'DESC']] });
204+
return res.status(200).json({
205+
message: "Found.",
206+
count: entries.length,
207+
limit,
208+
timeEntries: entries,
209+
});
210+
} catch (error) {
211+
log.error({ err: error }, 'TimeEntry.findAll failed');
212+
return res.status(500).json({ message: "Error!", error: String(error) });
213+
}
214+
};
215+
216+
/**
217+
* PATCH /v1/timeentry/:id — partial update.
218+
*
219+
* Only ALLOWED_FIELDS_UPDATE may be patched. teCompId / teCustId /
220+
* teArch / teMinutes are server-managed and not user-settable here.
221+
*/
222+
exports.update = async (req, res) => {
223+
const authKey = req.get('authKey');
224+
if (!authKey) {
225+
return res.status(403).json({ message: "Authorization key not sent." });
226+
}
227+
228+
let entry;
229+
try {
230+
entry = await TimeEntry.findByPk(req.params.id);
231+
} catch (error) {
232+
log.error({ err: error }, 'TimeEntry.findByPk failed');
233+
return res.status(500).json({ message: "Error!", error: String(error) });
234+
}
235+
if (!entry || entry.teArch) {
236+
return res.status(404).json({ message: "Not found." });
237+
}
238+
239+
const isMaster = await IsMaster(authKey);
240+
if (!isMaster) {
241+
const companyId = await GetCompanyId(authKey);
242+
if (companyId === -1 || entry.teCompId !== companyId) {
243+
return res.status(403).json({ message: "Invalid Authorization Key." });
244+
}
245+
}
246+
247+
const body = req.body || {};
248+
const updates = {};
249+
for (const f of ALLOWED_FIELDS_UPDATE) {
250+
if (body[f] !== undefined) updates[f] = body[f];
251+
}
252+
if (Object.keys(updates).length === 0) {
253+
return res.status(400).json({ message: "No updatable fields supplied." });
254+
}
255+
// Recompute minutes if either bound changed.
256+
if (updates.teStartedAt !== undefined || updates.teEndedAt !== undefined) {
257+
updates.teMinutes = computeMinutes(
258+
updates.teStartedAt !== undefined ? updates.teStartedAt : entry.teStartedAt,
259+
updates.teEndedAt !== undefined ? updates.teEndedAt : entry.teEndedAt,
260+
);
261+
}
262+
try {
263+
await entry.update(updates);
264+
return res.status(200).json({ message: "Updated.", timeEntry: entry });
265+
} catch (error) {
266+
log.error({ err: error }, 'TimeEntry.update failed');
267+
return res.status(500).json({ message: "Error!", error: String(error) });
268+
}
269+
};
270+
271+
/**
272+
* DELETE /v1/timeentry/:id — soft-delete (sets teArch = true).
273+
*
274+
* Time entries are never physically removed via the API.
275+
*/
276+
exports.remove = async (req, res) => {
277+
const authKey = req.get('authKey');
278+
if (!authKey) {
279+
return res.status(403).json({ message: "Authorization key not sent." });
280+
}
281+
282+
let entry;
283+
try {
284+
entry = await TimeEntry.findByPk(req.params.id);
285+
} catch (error) {
286+
log.error({ err: error }, 'TimeEntry.findByPk failed');
287+
return res.status(500).json({ message: "Error!", error: String(error) });
288+
}
289+
if (!entry || entry.teArch) {
290+
return res.status(404).json({ message: "Not found." });
291+
}
292+
293+
const isMaster = await IsMaster(authKey);
294+
if (!isMaster) {
295+
const companyId = await GetCompanyId(authKey);
296+
if (companyId === -1 || entry.teCompId !== companyId) {
297+
return res.status(403).json({ message: "Invalid Authorization Key." });
298+
}
299+
}
300+
301+
try {
302+
await entry.update({ teArch: true });
303+
return res.status(200).json({ message: "Archived.", id: entry.teId });
304+
} catch (error) {
305+
log.error({ err: error }, 'TimeEntry archive failed');
306+
return res.status(500).json({ message: "Error!", error: String(error) });
307+
}
308+
};
309+
310+
// Exposed for unit testing.
311+
exports._internals = { computeMinutes, IsMaster, GetCompanyId };

0 commit comments

Comments
 (0)