Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
server/node_modules
server/dist
147 changes: 147 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,24 @@ the dependencies by running:
docker compose run server npm i
```

### Production

To run the backend server in production run the build service, then start the server

```bash
docker compose run build
docker compose up server
```

### Dev

To run the backend server in the dev environment to reload and recompile on changes, start the build-watch and server-dev services

```bash
docker compose up build-watch server-dev
```


## Database

To bring up the database:
Expand All @@ -28,9 +46,138 @@ Once it's ready to go, you can run the schema migrator to build the schema:
docker compose run migrate
```

To seed the database with test data used in the jest tests run seed:
```bash
docker compose run seed
```

If that fails (because of something like an already existing table), you can always start with a clean slate
by bringing the DB container down:

```bash
docker compose down
```

To run jest tests run:
```bash
docker compose run test
```

## API Endpoints

The API provides two endpoints to calculate labor costs for workers and locations. Each endpoint allows filtering by task status (complete, incomplete, or both), worker IDs, and location IDs. These are `GET` requests which use query parameters to filter the response.

### 1. **Get Labor Cost by Worker**

- **Endpoint**: `/analytics/by-worker`
- **Method**: `GET`
- **Query Parameters**:
- `taskStatus` (required): Must be one of `"COMPLETE"`, `"INCOMPLETE"`, or `"BOTH"`. Determines whether to include completed tasks, incomplete tasks, or both.
- `workerIds` (optional): A comma-separated list of worker IDs to filter by specific workers (e.g., `workerIds=1,2,3`).
- `locationIds` (optional): A comma-separated list of location IDs to filter workers only working in specific locations (e.g., `locationIds=1,2`).

#### Example Request:

```bash
GET http://localhost:3000/analytics/by-worker?taskStatus=COMPLETE&workerIds=1,2,3&locationIds=3
```

#### Example Response:

```json
{
"data": {
"totalCost": 905,
"breakdown": [
{
"workerId": 1,
"workerName": "John Doe",
"totalCost": 150
},
{
"workerId": 2,
"workerName": "Jane Smith",
"totalCost": 300
},
{
"workerId": 3,
"workerName": "Bob Lee",
"totalCost": 455
}
]
},
}
```

### 2. **Get Labor Cost by Location**

- **Endpoint**: `/analytics/by-location`
- **Method**: `GET`
- **Query Parameters**:
- `taskStatus` (required): Must be one of `"COMPLETE"`, `"INCOMPLETE"`, or `"BOTH"`. Determines whether to include completed tasks, incomplete tasks, or both.
- `workerIds` (optional): A comma-separated list of worker IDs to filter only tasks performed by those workers (e.g., `workerIds=1,2`).
- `locationIds` (optional): A comma-separated list of location IDs to filter by specific locations (e.g., `locationIds=1,2,3`).

#### Example Request:

```bash
GET http://localhost:3000/analytics/by-location?taskStatus=INCOMPLETE&locationIds=1,2
```

#### Example Response:

```json
{
"data": {
"totalCost": 500,
"breakdown": [
{
"locationId": 1,
"locationName": "Warehouse A",
"totalCost": 300
},
{
"locationId": 2,
"locationName": "Head Office",
"totalCost": 200
}
]
},
}
```


## Error Handling

When a request contains invalid query parameters or the validation fails, the API responds with a structured error response in the following format:

### Example Error Response:

```json
{
"status": "error",
"message": "Validation failed",
"errors": [
{
"field": "taskStatus",
"message": "The 'taskStatus' query parameter is required."
},
{
"field": "workerIds",
"message": "workerIds query parameter must be a comma-separated list of non-negative integers"
}
]
}
```

Each error will contain:
- **field**: The query parameter that caused the error.
- **message**: A description of the error.


## TODO
- [ ] Implement pagination for large data sets in analytics endpoints
- [ ] Optimize database queries for better performance under high load or large data sets
- [ ] Add rate limiting to prevent abuse of the API


65 changes: 64 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,79 @@ services:
depends_on:
db:
condition: service_healthy

seed:
image: mariadb:11.3.2
command: ["/code/seed-db.sh"]
volumes:
- "./schema:/code"
environment:
DATABASE_HOST: db
DATABASE_USER: limble-test
DATABASE_PASSWORD: limble-test-password
DATABASE_NAME: limble
depends_on:
db:
condition: service_healthy

build:
image: node:21.7.1-bookworm-slim
working_dir: /code
volumes:
- "./server/:/code"
command: "npm run build"

build-watch:
image: node:21.7.1-bookworm-slim
working_dir: /code
volumes:
- "./server/:/code"
command: "npm run watch"

server:
image: node:21.7.1-bookworm-slim
command: ["npm", "start"]
command: "npm run start"
working_dir: /code
user: node
volumes:
- "./server/:/code"
ports:
- "0.0.0.0:3000:3000"
environment:
DATABASE_HOST: db
DATABASE_USER: limble-test
DATABASE_PASSWORD: limble-test-password
DATABASE_NAME: limble
NODE_ENV: production
depends_on:
db:
condition: service_healthy

server-dev:
image: node:21.7.1-bookworm-slim
command: "npm run dev"
working_dir: /code
user: node
volumes:
- "./server/:/code"
ports:
- "0.0.0.0:3000:3000"
environment:
DATABASE_HOST: db
DATABASE_USER: limble-test
DATABASE_PASSWORD: limble-test-password
DATABASE_NAME: limble
NODE_ENV: dev
depends_on:
db:
condition: service_healthy

test:
image: node:21.7.1-bookworm-slim
working_dir: /code
command: "npm run test"
volumes:
- "./server:/code"
environment:
DATABASE_HOST: db
DATABASE_USER: limble-test
Expand Down
2 changes: 2 additions & 0 deletions schema/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ CREATE TABLE tasks (

location_id INT(11) NOT NULL,

is_complete BOOLEAN NOT NULL DEFAULT 0,

FOREIGN KEY(location_id) REFERENCES locations(id) ON DELETE CASCADE
) ENGINE=INNODB;

Expand Down
4 changes: 4 additions & 0 deletions schema/seed-db.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
#/bin/bash

MYSQL_PWD="${DATABASE_PASSWORD}" mariadb -u "${DATABASE_USER}" -h "${DATABASE_HOST}" "${DATABASE_NAME}" < /code/seed-db.sql

39 changes: 39 additions & 0 deletions schema/seed-db.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
INSERT INTO locations (name) VALUES
('Warehouse'),
('Office'),
('Remote'),
('Factory'),
('Field');

INSERT INTO workers (username, hourly_wage) VALUES
('alice', 20.00),
('bob', 25.00),
('charlie', 30.00),
('dave', 35.00),
('eve', 40.00);

INSERT INTO tasks (description, location_id, is_complete) VALUES
('Inventory management', 1, true), -- Task in Warehouse, complete
('Office cleanup', 2, false), -- Task in Office, incomplete
('Remote client call', 3, true), -- Task remotely, complete
('Factory machine maintenance', 4, true), -- Task in Factory, complete
('Field survey', 5, false), -- Task in Field, incomplete
('Warehouse restocking', 1, true), -- Task in Warehouse, complete
('Office renovation', 2, false), -- Task in Office, incomplete
('Remote training', 3, true), -- Task remotely, complete
('Factory inspection', 4, false), -- Task in Factory, incomplete
('Field equipment setup', 5, true); -- Task in Field, complete

INSERT INTO logged_time (time_seconds, task_id, worker_id) VALUES
(7200, 1, 1),
(5400, 1, 2),
(10800, 2, 3),
(9000, 3, 1),
(3600, 3, 2),
(14400, 4, 4),
(9000, 5, 5),
(10800, 6, 1),
(7200, 6, 3),
(14400, 8, 2),
(12600, 9, 4),
(7200, 10, 5);
28 changes: 28 additions & 0 deletions server/app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import express from "express";
import morgan from "morgan";
import AppDataSource from "./db";
import {
costByLocationRoute,
costByWorkerRoute,
} from "./controllers/analytics.controller";

const app = express();
app.use(morgan(process.env.NODE_ENV === "production" ? "combined" : "dev"));

export const setupServer = async () => {
// Initialize the database
await AppDataSource.initialize();

// Register the analytics routes
const ANALYTICS_PREFIX = "/analytics";

app.get(`${ANALYTICS_PREFIX}/by-worker`, costByWorkerRoute);
app.get(`${ANALYTICS_PREFIX}/by-location`, costByLocationRoute);

// Register the 404 route
app.use((_req, res) => {
res.status(404).send("Not found");
});

return app;
};
Loading