Stage after merging with project files
This commit is contained in:
+5
-4
@@ -1,9 +1,10 @@
|
|||||||
APP_PORT=3000
|
PORT=3000
|
||||||
APP_URL=http://localhost:3000
|
APP_URL=http://localhost:3000
|
||||||
DB_HOST=db
|
DB_HOST=db
|
||||||
DB_PORT=3306
|
DB_PORT=3306
|
||||||
DB_NAME=app_db
|
DB_NAME=check_list
|
||||||
DB_USER=app_user
|
DB_USER=check_list_user
|
||||||
DB_PASSWORD=app_password
|
DB_PASSWORD=check_list_password
|
||||||
|
DB_CONNECTION_LIMIT=5
|
||||||
MARIADB_ROOT_PASSWORD=change_me_for_local_dev
|
MARIADB_ROOT_PASSWORD=change_me_for_local_dev
|
||||||
PHPMYADMIN_PORT=8080
|
PHPMYADMIN_PORT=8080
|
||||||
+2
-1
@@ -1,4 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
.env
|
.env
|
||||||
node_modules
|
|
||||||
.env.local
|
.env.local
|
||||||
|
dist/
|
||||||
npm-debug.log
|
npm-debug.log
|
||||||
@@ -1,46 +1,146 @@
|
|||||||
# CLProject Development Environment
|
# Check List Proof of Concept
|
||||||
|
|
||||||
This workspace now contains a containerized local development setup based on Node.js, MariaDB, phpMyAdmin, Docker Compose, and VS Code Dev Containers.
|
This repository contains a minimal proof-of-concept backend for the Check List hybrid reporting solution described in the project documentation.
|
||||||
|
|
||||||
## Services
|
## What is included
|
||||||
|
|
||||||
- `app`: Node.js development container built from `bitnami/node`
|
- Node.js REST API for template and configuration delivery
|
||||||
- `db`: MariaDB container built from `bitnami/mariadb`
|
- MariaDB schema for phase 1 configuration data
|
||||||
- `phpmyadmin`: phpMyAdmin container for database inspection
|
- seed data with one sample inspection checklist template
|
||||||
|
- lookup values, image policy, and export profile
|
||||||
|
- Docker Compose and VS Code Dev Container setup for local development
|
||||||
|
|
||||||
## Open In VS Code
|
## Scope of this PoC
|
||||||
|
|
||||||
1. Open this workspace in VS Code.
|
Included:
|
||||||
2. Run `Dev Containers: Reopen in Container`.
|
- template list endpoint
|
||||||
3. Wait for the `app` service to install dependencies and start the server.
|
- active template endpoint
|
||||||
|
- specific template version endpoint
|
||||||
|
- lookup endpoints
|
||||||
|
- image rule endpoint
|
||||||
|
- export profile endpoint
|
||||||
|
- generic application config endpoint
|
||||||
|
- MariaDB schema and seed data
|
||||||
|
|
||||||
The mounted workspace remains editable from VS Code while running inside the container.
|
Not included:
|
||||||
|
- report upload
|
||||||
|
- authentication
|
||||||
|
- admin UI
|
||||||
|
- report draft storage backend
|
||||||
|
- XLSX or ZIP generation
|
||||||
|
- client-side offline application
|
||||||
|
|
||||||
## Ports
|
The PoC keeps template content inside a JSON column to reduce initial complexity and speed up delivery. This is deliberate for phase 1 proof-of-concept work.
|
||||||
|
|
||||||
- App: `http://localhost:3000`
|
## Project structure
|
||||||
|
|
||||||
|
```text
|
||||||
|
.
|
||||||
|
├── .devcontainer/
|
||||||
|
├── docker-compose.yml
|
||||||
|
├── package.json
|
||||||
|
├── scripts/
|
||||||
|
│ └── test-environment.js
|
||||||
|
├── sql/
|
||||||
|
│ ├── schema.sql
|
||||||
|
│ └── seed.sql
|
||||||
|
└── src/
|
||||||
|
├── app.js
|
||||||
|
├── server.js
|
||||||
|
├── config/
|
||||||
|
├── db/
|
||||||
|
├── middleware/
|
||||||
|
├── routes/
|
||||||
|
├── services/
|
||||||
|
└── utils/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run with Docker and Dev Containers
|
||||||
|
|
||||||
|
1. Copy `.env.example` to `.env` if you want custom local credentials.
|
||||||
|
2. In VS Code, run `Dev Containers: Reopen in Container`.
|
||||||
|
3. Or start the stack directly with `docker compose up -d --build`.
|
||||||
|
|
||||||
|
Services:
|
||||||
|
- API: `http://localhost:3000`
|
||||||
- phpMyAdmin: `http://localhost:8080`
|
- phpMyAdmin: `http://localhost:8080`
|
||||||
- MariaDB: `localhost:3306`
|
- MariaDB: `localhost:3306`
|
||||||
|
|
||||||
## Validate The Environment
|
The workspace is bind-mounted into the `app` container, so project files remain editable from VS Code.
|
||||||
|
|
||||||
After the containers are up, run:
|
## Database bootstrap
|
||||||
|
|
||||||
|
On a fresh MariaDB volume, Docker loads `sql/schema.sql` and `sql/seed.sql` automatically.
|
||||||
|
|
||||||
|
If you already have an older local database volume, either recreate it with `docker compose down -v` or import the SQL files manually:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec -T db sh -lc 'mariadb -uroot -p"$MARIADB_ROOT_PASSWORD" < /docker-entrypoint-initdb.d/schema.sql && mariadb -uroot -p"$MARIADB_ROOT_PASSWORD" < /docker-entrypoint-initdb.d/seed.sql'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Validate the environment
|
||||||
|
|
||||||
|
Run the smoke test after the containers are up:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run test:environment
|
npm run test:environment
|
||||||
```
|
```
|
||||||
|
|
||||||
The test checks that:
|
The test verifies:
|
||||||
|
- the API health endpoint
|
||||||
|
- seeded template data via `/api/templates`
|
||||||
|
- direct MariaDB connectivity
|
||||||
|
- phpMyAdmin availability
|
||||||
|
|
||||||
- the Node.js app is reachable
|
## API endpoints
|
||||||
- the app can talk to MariaDB
|
|
||||||
- the seed table was created successfully
|
|
||||||
|
|
||||||
## Database Login
|
### Service health
|
||||||
|
|
||||||
- Server: `db`
|
`GET /api/health`
|
||||||
- Database: `app_db`
|
|
||||||
- User: `app_user`
|
|
||||||
- Password: `app_password`
|
|
||||||
|
|
||||||
Root password is configured in `.env` for local development.
|
### Templates
|
||||||
|
|
||||||
|
- `GET /api/templates`
|
||||||
|
- `GET /api/templates/incoming-inspection`
|
||||||
|
- `GET /api/templates/incoming-inspection/versions/1`
|
||||||
|
|
||||||
|
### Lookups
|
||||||
|
|
||||||
|
- `GET /api/lookups`
|
||||||
|
- `GET /api/lookups/pass-fail`
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
- `GET /api/config/image-rules`
|
||||||
|
- `GET /api/config/export`
|
||||||
|
- `GET /api/config/app-config`
|
||||||
|
|
||||||
|
## Example response
|
||||||
|
|
||||||
|
`GET /api/templates/incoming-inspection`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": "incoming-inspection",
|
||||||
|
"name": "Incoming Inspection Checklist",
|
||||||
|
"description": "PoC template for supplier or incoming goods quality inspection.",
|
||||||
|
"version": 1,
|
||||||
|
"status": "active",
|
||||||
|
"publishedAt": "2026-04-09T10:00:00.000Z",
|
||||||
|
"definition": {
|
||||||
|
"templateId": "incoming-inspection",
|
||||||
|
"templateName": "Incoming Inspection Checklist",
|
||||||
|
"version": 1,
|
||||||
|
"sections": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Recommended next step after this PoC
|
||||||
|
|
||||||
|
The next logical implementation layer is the client application that:
|
||||||
|
- caches templates in IndexedDB,
|
||||||
|
- renders forms dynamically from `definition`,
|
||||||
|
- stores local drafts and image metadata,
|
||||||
|
- applies validation rules before export,
|
||||||
|
- generates XLSX and ZIP locally.
|
||||||
+19
-9
@@ -7,28 +7,38 @@ services:
|
|||||||
- .env
|
- .env
|
||||||
working_dir: /workspace
|
working_dir: /workspace
|
||||||
command: >-
|
command: >-
|
||||||
sh -lc "if [ ! -d node_modules ]; then npm install --no-fund --no-audit; fi && npm run dev"
|
sh -lc "npm install --no-fund --no-audit && npm run dev"
|
||||||
volumes:
|
volumes:
|
||||||
- .:/workspace:cached
|
- .:/workspace:cached
|
||||||
ports:
|
ports:
|
||||||
- "${APP_PORT:-3000}:${APP_PORT:-3000}"
|
- "${PORT:-3000}:${PORT:-3000}"
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: bitnami/mariadb:latest
|
image: bitnami/mariadb:latest
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
MARIADB_DATABASE: ${DB_NAME:-app_db}
|
MARIADB_DATABASE: ${DB_NAME:-check_list}
|
||||||
MARIADB_USER: ${DB_USER:-app_user}
|
MARIADB_USER: ${DB_USER:-check_list_user}
|
||||||
MARIADB_PASSWORD: ${DB_PASSWORD:-app_password}
|
MARIADB_PASSWORD: ${DB_PASSWORD:-check_list_password}
|
||||||
MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD:-root_password}
|
MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD:-root_password}
|
||||||
ports:
|
ports:
|
||||||
- "${DB_PORT:-3306}:3306"
|
- "${DB_PORT:-3306}:3306"
|
||||||
volumes:
|
volumes:
|
||||||
- mariadb_data:/bitnami/mariadb
|
- mariadb_data:/bitnami/mariadb
|
||||||
- ./docker/mariadb/init:/docker-entrypoint-initdb.d:ro
|
- ./sql:/docker-entrypoint-initdb.d:ro
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
- CMD-SHELL
|
||||||
|
- mariadb-admin ping -h 127.0.0.1 -uroot -p$$MARIADB_ROOT_PASSWORD --silent
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 20
|
||||||
|
start_period: 15s
|
||||||
|
|
||||||
phpmyadmin:
|
phpmyadmin:
|
||||||
image: phpmyadmin:5-apache
|
image: phpmyadmin:5-apache
|
||||||
@@ -37,8 +47,8 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
PMA_HOST: db
|
PMA_HOST: db
|
||||||
PMA_PORT: 3306
|
PMA_PORT: 3306
|
||||||
PMA_USER: ${DB_USER:-app_user}
|
PMA_USER: ${DB_USER:-check_list_user}
|
||||||
PMA_PASSWORD: ${DB_PASSWORD:-app_password}
|
PMA_PASSWORD: ${DB_PASSWORD:-check_list_password}
|
||||||
ports:
|
ports:
|
||||||
- "${PHPMYADMIN_PORT:-8080}:80"
|
- "${PHPMYADMIN_PORT:-8080}:80"
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
CREATE TABLE IF NOT EXISTS environment_checks (
|
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
name VARCHAR(100) NOT NULL UNIQUE,
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
INSERT INTO environment_checks (name)
|
|
||||||
VALUES ('containers-online')
|
|
||||||
ON DUPLICATE KEY UPDATE name = VALUES(name);
|
|
||||||
Generated
+35
-5
@@ -1,16 +1,20 @@
|
|||||||
{
|
{
|
||||||
"name": "clproject-env-test",
|
"name": "check-list-poc-api",
|
||||||
"version": "1.0.0",
|
"version": "0.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "clproject-env-test",
|
"name": "check-list-poc-api",
|
||||||
"version": "1.0.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dotenv": "^16.4.7",
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"mariadb": "^3.4.0"
|
"mariadb": "^3.4.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/geojson": {
|
"node_modules/@types/geojson": {
|
||||||
@@ -145,6 +149,23 @@
|
|||||||
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/cors": {
|
||||||
|
"version": "2.8.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
|
||||||
|
"integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"object-assign": "^4",
|
||||||
|
"vary": "^1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "2.6.9",
|
"version": "2.6.9",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||||
@@ -601,6 +622,15 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/object-assign": {
|
||||||
|
"version": "4.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
|
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/object-inspect": {
|
"node_modules/object-inspect": {
|
||||||
"version": "1.13.4",
|
"version": "1.13.4",
|
||||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||||
|
|||||||
+10
-6
@@ -1,16 +1,20 @@
|
|||||||
{
|
{
|
||||||
"name": "clproject-env-test",
|
"name": "check-list-poc-api",
|
||||||
"version": "1.0.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"description": "Proof-of-concept backend for the Check List hybrid quality reporting solution.",
|
||||||
"description": "Containerized Node.js + MariaDB development environment smoke test",
|
"type": "module",
|
||||||
"main": "src/server.js",
|
"main": "src/server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "node --watch src/server.js",
|
|
||||||
"start": "node src/server.js",
|
"start": "node src/server.js",
|
||||||
|
"dev": "node --watch src/server.js",
|
||||||
"test:environment": "node scripts/test-environment.js"
|
"test:environment": "node scripts/test-environment.js"
|
||||||
},
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dotenv": "^16.4.7",
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"mariadb": "^3.4.0"
|
"mariadb": "^3.4.0"
|
||||||
}
|
}
|
||||||
|
|||||||
+84
-41
@@ -1,44 +1,50 @@
|
|||||||
require("dotenv").config();
|
import 'dotenv/config';
|
||||||
|
|
||||||
const assert = require("node:assert/strict");
|
import assert from 'node:assert/strict';
|
||||||
const http = require("node:http");
|
import http from 'node:http';
|
||||||
const https = require("node:https");
|
import https from 'node:https';
|
||||||
const mariadb = require("mariadb");
|
|
||||||
|
import * as mariadb from 'mariadb';
|
||||||
|
|
||||||
function unique(values) {
|
function unique(values) {
|
||||||
return [...new Set(values.filter(Boolean))];
|
return [...new Set(values.filter(Boolean))];
|
||||||
}
|
}
|
||||||
|
|
||||||
function getJson(targetUrl) {
|
function requestUrl(targetUrl) {
|
||||||
const url = new URL(targetUrl);
|
const url = new URL(targetUrl);
|
||||||
const transport = url.protocol === "https:" ? https : http;
|
const transport = url.protocol === 'https:' ? https : http;
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const request = transport.get(url, (response) => {
|
const request = transport.get(url, (response) => {
|
||||||
let body = "";
|
let body = '';
|
||||||
|
|
||||||
response.on("data", (chunk) => {
|
response.on('data', (chunk) => {
|
||||||
body += chunk;
|
body += chunk;
|
||||||
});
|
});
|
||||||
|
|
||||||
response.on("end", () => {
|
response.on('end', () => {
|
||||||
try {
|
resolve({
|
||||||
resolve({
|
body,
|
||||||
body: JSON.parse(body),
|
statusCode: response.statusCode
|
||||||
statusCode: response.statusCode
|
});
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
reject(error);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
request.on("error", reject);
|
request.on('error', reject);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function testApp() {
|
async function getJson(targetUrl) {
|
||||||
const appPort = Number(process.env.APP_PORT || 3000);
|
const response = await requestUrl(targetUrl);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...response,
|
||||||
|
json: JSON.parse(response.body)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testApi() {
|
||||||
|
const appPort = Number(process.env.PORT || 3000);
|
||||||
const candidates = unique([
|
const candidates = unique([
|
||||||
process.env.APP_URL,
|
process.env.APP_URL,
|
||||||
`http://localhost:${appPort}`,
|
`http://localhost:${appPort}`,
|
||||||
@@ -49,13 +55,24 @@ async function testApp() {
|
|||||||
|
|
||||||
for (const baseUrl of candidates) {
|
for (const baseUrl of candidates) {
|
||||||
try {
|
try {
|
||||||
const response = await getJson(`${baseUrl}/health`);
|
const health = await getJson(`${baseUrl}/api/health`);
|
||||||
|
|
||||||
assert.equal(response.statusCode, 200, `Unexpected status from ${baseUrl}`);
|
assert.equal(health.statusCode, 200, `Unexpected health status from ${baseUrl}`);
|
||||||
assert.equal(response.body.status, "ok", "Application health endpoint is not healthy");
|
assert.equal(health.json.status, 'ok', 'API health endpoint is not healthy');
|
||||||
assert.equal(response.body.database, "reachable", "Application cannot reach MariaDB");
|
assert.equal(health.json.service, 'check-list-poc-api', 'Unexpected API service name');
|
||||||
|
assert.equal(health.json.database, 'connected', 'API cannot reach MariaDB');
|
||||||
|
|
||||||
return { baseUrl, payload: response.body };
|
const templates = await getJson(`${baseUrl}/api/templates`);
|
||||||
|
|
||||||
|
assert.equal(templates.statusCode, 200, `Unexpected templates status from ${baseUrl}`);
|
||||||
|
assert.ok(Array.isArray(templates.json.items), 'Templates response must contain an items array');
|
||||||
|
assert.ok(templates.json.items.length >= 1, 'Seeded templates are missing');
|
||||||
|
|
||||||
|
return {
|
||||||
|
baseUrl,
|
||||||
|
health: health.json,
|
||||||
|
templates: templates.json.items
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = error;
|
lastError = error;
|
||||||
}
|
}
|
||||||
@@ -67,9 +84,9 @@ async function testApp() {
|
|||||||
async function testDatabase() {
|
async function testDatabase() {
|
||||||
const candidates = unique([
|
const candidates = unique([
|
||||||
process.env.DB_HOST,
|
process.env.DB_HOST,
|
||||||
"127.0.0.1",
|
'127.0.0.1',
|
||||||
"localhost",
|
'localhost',
|
||||||
"db"
|
'db'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let lastError;
|
let lastError;
|
||||||
@@ -78,22 +95,22 @@ async function testDatabase() {
|
|||||||
const pool = mariadb.createPool({
|
const pool = mariadb.createPool({
|
||||||
host,
|
host,
|
||||||
port: Number(process.env.DB_PORT || 3306),
|
port: Number(process.env.DB_PORT || 3306),
|
||||||
user: process.env.DB_USER || "app_user",
|
user: process.env.DB_USER || 'check_list_user',
|
||||||
password: process.env.DB_PASSWORD || "app_password",
|
password: process.env.DB_PASSWORD || 'check_list_password',
|
||||||
database: process.env.DB_NAME || "app_db",
|
database: process.env.DB_NAME || 'check_list',
|
||||||
connectionLimit: 1
|
connectionLimit: 1
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const connection = await pool.getConnection();
|
const connection = await pool.getConnection();
|
||||||
const rows = await connection.query(
|
const rows = await connection.query(
|
||||||
"SELECT name FROM environment_checks ORDER BY id"
|
'SELECT code FROM templates ORDER BY id'
|
||||||
);
|
);
|
||||||
|
|
||||||
connection.release();
|
connection.release();
|
||||||
await pool.end();
|
await pool.end();
|
||||||
|
|
||||||
assert.ok(rows.length >= 1, "Seed table is empty");
|
assert.ok(rows.some((row) => row.code === 'incoming-inspection'), 'Expected seed template is missing');
|
||||||
|
|
||||||
return { host, rows };
|
return { host, rows };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -105,18 +122,44 @@ async function testDatabase() {
|
|||||||
throw lastError;
|
throw lastError;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function testPhpMyAdmin() {
|
||||||
const appResult = await testApp();
|
const candidates = unique([
|
||||||
const databaseResult = await testDatabase();
|
'http://phpmyadmin/',
|
||||||
|
`http://localhost:${process.env.PHPMYADMIN_PORT || 8080}/`
|
||||||
|
]);
|
||||||
|
|
||||||
console.log("Environment test passed");
|
let lastError;
|
||||||
console.log(`App endpoint: ${appResult.baseUrl}`);
|
|
||||||
|
for (const url of candidates) {
|
||||||
|
try {
|
||||||
|
const response = await requestUrl(url);
|
||||||
|
|
||||||
|
assert.equal(response.statusCode, 200, `Unexpected phpMyAdmin status from ${url}`);
|
||||||
|
assert.match(response.body, /phpMyAdmin/i, 'phpMyAdmin landing page did not load');
|
||||||
|
|
||||||
|
return { url };
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const apiResult = await testApi();
|
||||||
|
const databaseResult = await testDatabase();
|
||||||
|
const phpMyAdminResult = await testPhpMyAdmin();
|
||||||
|
|
||||||
|
console.log('Environment test passed');
|
||||||
|
console.log(`API endpoint: ${apiResult.baseUrl}`);
|
||||||
|
console.log(`Seed templates: ${apiResult.templates.length}`);
|
||||||
console.log(`Database host: ${databaseResult.host}`);
|
console.log(`Database host: ${databaseResult.host}`);
|
||||||
console.log(`Seed rows: ${databaseResult.rows.length}`);
|
console.log(`phpMyAdmin: ${phpMyAdminResult.url}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((error) => {
|
main().catch((error) => {
|
||||||
console.error("Environment test failed");
|
console.error('Environment test failed');
|
||||||
console.error(error.message);
|
console.error(error.message);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
CREATE DATABASE IF NOT EXISTS check_list CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
USE check_list;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS templates (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
code VARCHAR(100) NOT NULL,
|
||||||
|
name VARCHAR(200) NOT NULL,
|
||||||
|
description TEXT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uq_templates_code (code)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS template_versions (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
template_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
version_number INT NOT NULL,
|
||||||
|
status ENUM('draft', 'active', 'retired') NOT NULL DEFAULT 'draft',
|
||||||
|
definition_json JSON NOT NULL,
|
||||||
|
published_at DATETIME NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uq_template_version (template_id, version_number),
|
||||||
|
KEY idx_template_versions_template_status (template_id, status),
|
||||||
|
CONSTRAINT fk_template_versions_template
|
||||||
|
FOREIGN KEY (template_id) REFERENCES templates (id)
|
||||||
|
ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS lookup_sets (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
code VARCHAR(100) NOT NULL,
|
||||||
|
name VARCHAR(200) NOT NULL,
|
||||||
|
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uq_lookup_sets_code (code)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS lookup_values (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
lookup_set_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
value VARCHAR(100) NOT NULL,
|
||||||
|
label VARCHAR(200) NOT NULL,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
is_default TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uq_lookup_value (lookup_set_id, value),
|
||||||
|
KEY idx_lookup_values_lookup_set (lookup_set_id),
|
||||||
|
CONSTRAINT fk_lookup_values_lookup_set
|
||||||
|
FOREIGN KEY (lookup_set_id) REFERENCES lookup_sets (id)
|
||||||
|
ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS image_rules (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
code VARCHAR(100) NOT NULL,
|
||||||
|
name VARCHAR(200) NOT NULL,
|
||||||
|
allowed_mime_types_json JSON NOT NULL,
|
||||||
|
max_file_size_bytes INT UNSIGNED NOT NULL,
|
||||||
|
max_width_px INT UNSIGNED NOT NULL,
|
||||||
|
max_height_px INT UNSIGNED NOT NULL,
|
||||||
|
jpeg_quality INT UNSIGNED NOT NULL,
|
||||||
|
oversize_behavior ENUM('auto_optimize', 'warn_then_optimize', 'block') NOT NULL DEFAULT 'auto_optimize',
|
||||||
|
max_attachments_per_field INT UNSIGNED NOT NULL DEFAULT 5,
|
||||||
|
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uq_image_rules_code (code)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS export_profiles (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
code VARCHAR(100) NOT NULL,
|
||||||
|
name VARCHAR(200) NOT NULL,
|
||||||
|
zip_image_dir VARCHAR(100) NOT NULL DEFAULT 'images',
|
||||||
|
excel_sheet_name VARCHAR(100) NOT NULL DEFAULT 'Checklist',
|
||||||
|
include_template_version TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
include_export_timestamp TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uq_export_profiles_code (code)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS app_config (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
config_key VARCHAR(100) NOT NULL,
|
||||||
|
config_value_json JSON NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uq_app_config_key (config_key)
|
||||||
|
);
|
||||||
+220
@@ -0,0 +1,220 @@
|
|||||||
|
USE check_list;
|
||||||
|
|
||||||
|
INSERT INTO templates (code, name, description)
|
||||||
|
VALUES (
|
||||||
|
'incoming-inspection',
|
||||||
|
'Incoming Inspection Checklist',
|
||||||
|
'PoC template for supplier or incoming goods quality inspection.'
|
||||||
|
)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
name = VALUES(name),
|
||||||
|
description = VALUES(description);
|
||||||
|
|
||||||
|
SET @template_id = (SELECT id FROM templates WHERE code = 'incoming-inspection');
|
||||||
|
|
||||||
|
INSERT INTO template_versions (
|
||||||
|
template_id,
|
||||||
|
version_number,
|
||||||
|
status,
|
||||||
|
definition_json,
|
||||||
|
published_at
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
@template_id,
|
||||||
|
1,
|
||||||
|
'active',
|
||||||
|
'{
|
||||||
|
"templateId": "incoming-inspection",
|
||||||
|
"templateName": "Incoming Inspection Checklist",
|
||||||
|
"version": 1,
|
||||||
|
"reportNumberPattern": "QC-{yyyy}-{seq:4}",
|
||||||
|
"exportProfileCode": "default-report-export",
|
||||||
|
"imageRuleCode": "standard-mobile-images",
|
||||||
|
"sections": [
|
||||||
|
{
|
||||||
|
"id": "header",
|
||||||
|
"title": "Report Header",
|
||||||
|
"type": "group",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"id": "reportNumber",
|
||||||
|
"label": "Report Number",
|
||||||
|
"type": "text",
|
||||||
|
"required": true,
|
||||||
|
"readOnly": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "inspectionDate",
|
||||||
|
"label": "Inspection Date",
|
||||||
|
"type": "date",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "supplierName",
|
||||||
|
"label": "Supplier",
|
||||||
|
"type": "text",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "batchNumber",
|
||||||
|
"label": "Batch Number",
|
||||||
|
"type": "text",
|
||||||
|
"required": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "check-items",
|
||||||
|
"title": "Inspection Items",
|
||||||
|
"type": "group",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"id": "packagingCondition",
|
||||||
|
"label": "Packaging Condition",
|
||||||
|
"type": "lookup",
|
||||||
|
"lookupCode": "pass-fail",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "labelCheck",
|
||||||
|
"label": "Label Verification",
|
||||||
|
"type": "lookup",
|
||||||
|
"lookupCode": "pass-fail",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "quantityVerified",
|
||||||
|
"label": "Quantity Verified",
|
||||||
|
"type": "number",
|
||||||
|
"required": true,
|
||||||
|
"validation": {
|
||||||
|
"min": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "damageFound",
|
||||||
|
"label": "Visible Damage Found",
|
||||||
|
"type": "checkbox",
|
||||||
|
"required": false,
|
||||||
|
"defaultValue": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "damagePhoto",
|
||||||
|
"label": "Damage Photo",
|
||||||
|
"type": "attachment",
|
||||||
|
"requiredWhen": {
|
||||||
|
"field": "damageFound",
|
||||||
|
"equals": true
|
||||||
|
},
|
||||||
|
"maxAttachments": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "inspectorComment",
|
||||||
|
"label": "Inspector Comment",
|
||||||
|
"type": "comment",
|
||||||
|
"required": false,
|
||||||
|
"maxLength": 1000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}',
|
||||||
|
NOW()
|
||||||
|
)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
status = VALUES(status),
|
||||||
|
definition_json = VALUES(definition_json),
|
||||||
|
published_at = VALUES(published_at);
|
||||||
|
|
||||||
|
INSERT INTO lookup_sets (code, name)
|
||||||
|
VALUES
|
||||||
|
('pass-fail', 'Pass/Fail'),
|
||||||
|
('draft-status', 'Draft Status')
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
name = VALUES(name);
|
||||||
|
|
||||||
|
SET @pass_fail_id = (SELECT id FROM lookup_sets WHERE code = 'pass-fail');
|
||||||
|
SET @draft_status_id = (SELECT id FROM lookup_sets WHERE code = 'draft-status');
|
||||||
|
|
||||||
|
INSERT INTO lookup_values (lookup_set_id, value, label, sort_order, is_default)
|
||||||
|
VALUES
|
||||||
|
(@pass_fail_id, 'pass', 'Pass', 1, 1),
|
||||||
|
(@pass_fail_id, 'fail', 'Fail', 2, 0),
|
||||||
|
(@draft_status_id, 'draft', 'Draft', 1, 1),
|
||||||
|
(@draft_status_id, 'in_progress', 'In Progress', 2, 0),
|
||||||
|
(@draft_status_id, 'ready_for_export', 'Ready for Export', 3, 0),
|
||||||
|
(@draft_status_id, 'exported', 'Exported', 4, 0),
|
||||||
|
(@draft_status_id, 'archived', 'Archived', 5, 0)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
label = VALUES(label),
|
||||||
|
sort_order = VALUES(sort_order),
|
||||||
|
is_default = VALUES(is_default);
|
||||||
|
|
||||||
|
INSERT INTO image_rules (
|
||||||
|
code,
|
||||||
|
name,
|
||||||
|
allowed_mime_types_json,
|
||||||
|
max_file_size_bytes,
|
||||||
|
max_width_px,
|
||||||
|
max_height_px,
|
||||||
|
jpeg_quality,
|
||||||
|
oversize_behavior,
|
||||||
|
max_attachments_per_field,
|
||||||
|
is_active
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
'standard-mobile-images',
|
||||||
|
'Standard Mobile Image Policy',
|
||||||
|
'["image/jpeg", "image/png", "image/webp"]',
|
||||||
|
5242880,
|
||||||
|
1920,
|
||||||
|
1920,
|
||||||
|
82,
|
||||||
|
'auto_optimize',
|
||||||
|
5,
|
||||||
|
1
|
||||||
|
)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
name = VALUES(name),
|
||||||
|
allowed_mime_types_json = VALUES(allowed_mime_types_json),
|
||||||
|
max_file_size_bytes = VALUES(max_file_size_bytes),
|
||||||
|
max_width_px = VALUES(max_width_px),
|
||||||
|
max_height_px = VALUES(max_height_px),
|
||||||
|
jpeg_quality = VALUES(jpeg_quality),
|
||||||
|
oversize_behavior = VALUES(oversize_behavior),
|
||||||
|
max_attachments_per_field = VALUES(max_attachments_per_field),
|
||||||
|
is_active = VALUES(is_active);
|
||||||
|
|
||||||
|
INSERT INTO export_profiles (
|
||||||
|
code,
|
||||||
|
name,
|
||||||
|
zip_image_dir,
|
||||||
|
excel_sheet_name,
|
||||||
|
include_template_version,
|
||||||
|
include_export_timestamp,
|
||||||
|
is_active
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
'default-report-export',
|
||||||
|
'Default Report Export',
|
||||||
|
'images',
|
||||||
|
'Checklist',
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1
|
||||||
|
)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
name = VALUES(name),
|
||||||
|
zip_image_dir = VALUES(zip_image_dir),
|
||||||
|
excel_sheet_name = VALUES(excel_sheet_name),
|
||||||
|
include_template_version = VALUES(include_template_version),
|
||||||
|
include_export_timestamp = VALUES(include_export_timestamp),
|
||||||
|
is_active = VALUES(is_active);
|
||||||
|
|
||||||
|
INSERT INTO app_config (config_key, config_value_json)
|
||||||
|
VALUES
|
||||||
|
('autosave', '{"enabled": true, "intervalSeconds": 20}'),
|
||||||
|
('offlineCache', '{"templateTtlHours": 24, "refreshOnStartup": true}'),
|
||||||
|
('reportStatuses', '["draft", "in_progress", "ready_for_export", "exported", "archived"]')
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
config_value_json = VALUES(config_value_json);
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
import cors from 'cors';
|
||||||
|
import express from 'express';
|
||||||
|
|
||||||
|
import { errorHandler, notFoundHandler } from './middleware/errorHandler.js';
|
||||||
|
import configRoutes from './routes/configRoutes.js';
|
||||||
|
import healthRoutes from './routes/healthRoutes.js';
|
||||||
|
import lookupRoutes from './routes/lookupRoutes.js';
|
||||||
|
import templateRoutes from './routes/templateRoutes.js';
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json({ limit: '10mb' }));
|
||||||
|
|
||||||
|
app.get('/', (_req, res) => {
|
||||||
|
res.json({
|
||||||
|
service: 'check-list-poc-api',
|
||||||
|
version: '0.1.0',
|
||||||
|
description: 'PoC API for template and configuration delivery.'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use('/api/health', healthRoutes);
|
||||||
|
app.use('/api/templates', templateRoutes);
|
||||||
|
app.use('/api/lookups', lookupRoutes);
|
||||||
|
app.use('/api/config', configRoutes);
|
||||||
|
|
||||||
|
app.use(notFoundHandler);
|
||||||
|
app.use(errorHandler);
|
||||||
|
|
||||||
|
export default app;
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
const requiredKeys = ['DB_HOST', 'DB_PORT', 'DB_NAME', 'DB_USER', 'DB_PASSWORD'];
|
||||||
|
|
||||||
|
for (const key of requiredKeys) {
|
||||||
|
if (!process.env[key]) {
|
||||||
|
throw new Error(`Missing required environment variable: ${key}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const env = {
|
||||||
|
port: Number(process.env.PORT || 3000),
|
||||||
|
db: {
|
||||||
|
host: process.env.DB_HOST,
|
||||||
|
port: Number(process.env.DB_PORT),
|
||||||
|
database: process.env.DB_NAME,
|
||||||
|
user: process.env.DB_USER,
|
||||||
|
password: process.env.DB_PASSWORD,
|
||||||
|
connectionLimit: Number(process.env.DB_CONNECTION_LIMIT || 5)
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
const mariadb = require("mariadb");
|
|
||||||
|
|
||||||
const pool = mariadb.createPool({
|
|
||||||
host: process.env.DB_HOST || "db",
|
|
||||||
port: Number(process.env.DB_PORT || 3306),
|
|
||||||
user: process.env.DB_USER || "app_user",
|
|
||||||
password: process.env.DB_PASSWORD || "app_password",
|
|
||||||
database: process.env.DB_NAME || "app_db",
|
|
||||||
connectionLimit: 5
|
|
||||||
});
|
|
||||||
|
|
||||||
async function executeQuery(sql, params = []) {
|
|
||||||
let connection;
|
|
||||||
|
|
||||||
try {
|
|
||||||
connection = await pool.getConnection();
|
|
||||||
return await connection.query(sql, params);
|
|
||||||
} finally {
|
|
||||||
if (connection) {
|
|
||||||
connection.release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function closePool() {
|
|
||||||
await pool.end();
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
closePool,
|
|
||||||
executeQuery
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import * as mariadb from 'mariadb';
|
||||||
|
|
||||||
|
import { env } from '../config/env.js';
|
||||||
|
|
||||||
|
const pool = mariadb.createPool({
|
||||||
|
host: env.db.host,
|
||||||
|
port: env.db.port,
|
||||||
|
database: env.db.database,
|
||||||
|
user: env.db.user,
|
||||||
|
password: env.db.password,
|
||||||
|
connectionLimit: env.db.connectionLimit,
|
||||||
|
bigIntAsNumber: true
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function query(sql, params = []) {
|
||||||
|
let connection;
|
||||||
|
|
||||||
|
try {
|
||||||
|
connection = await pool.getConnection();
|
||||||
|
return await connection.query(sql, params);
|
||||||
|
} finally {
|
||||||
|
if (connection) {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closePool() {
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export function notFoundHandler(_req, res) {
|
||||||
|
res.status(404).json({ message: 'Route not found.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function errorHandler(error, _req, res, _next) {
|
||||||
|
const statusCode = error.statusCode || 500;
|
||||||
|
|
||||||
|
if (statusCode >= 500) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(statusCode).json({
|
||||||
|
message: error.message || 'Unexpected server error.'
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getAppConfig,
|
||||||
|
getExportProfile,
|
||||||
|
getImageRules
|
||||||
|
} from '../services/configService.js';
|
||||||
|
import { asyncHandler } from '../utils/asyncHandler.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/image-rules',
|
||||||
|
asyncHandler(async (_req, res) => {
|
||||||
|
const imageRules = await getImageRules();
|
||||||
|
|
||||||
|
if (!imageRules) {
|
||||||
|
return res.status(404).json({ message: 'Image rules not found.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(imageRules);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/export',
|
||||||
|
asyncHandler(async (_req, res) => {
|
||||||
|
const exportProfile = await getExportProfile();
|
||||||
|
|
||||||
|
if (!exportProfile) {
|
||||||
|
return res.status(404).json({ message: 'Export profile not found.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(exportProfile);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/app-config',
|
||||||
|
asyncHandler(async (_req, res) => {
|
||||||
|
const config = await getAppConfig();
|
||||||
|
res.json({ items: config });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
|
||||||
|
import { query } from '../db/pool.js';
|
||||||
|
import { asyncHandler } from '../utils/asyncHandler.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/',
|
||||||
|
asyncHandler(async (_req, res) => {
|
||||||
|
await query('SELECT 1 AS ok');
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
status: 'ok',
|
||||||
|
service: 'check-list-poc-api',
|
||||||
|
database: 'connected'
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
|
||||||
|
import { getLookup, listLookups } from '../services/lookupService.js';
|
||||||
|
import { asyncHandler } from '../utils/asyncHandler.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/',
|
||||||
|
asyncHandler(async (_req, res) => {
|
||||||
|
const lookups = await listLookups();
|
||||||
|
res.json({ items: lookups });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/:lookupCode',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const lookup = await getLookup(req.params.lookupCode);
|
||||||
|
|
||||||
|
if (!lookup) {
|
||||||
|
return res.status(404).json({ message: 'Lookup not found.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json(lookup);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getActiveTemplate,
|
||||||
|
getTemplateVersion,
|
||||||
|
listTemplates
|
||||||
|
} from '../services/templateService.js';
|
||||||
|
import { asyncHandler } from '../utils/asyncHandler.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/',
|
||||||
|
asyncHandler(async (_req, res) => {
|
||||||
|
const templates = await listTemplates();
|
||||||
|
res.json({ items: templates });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/:templateCode',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const template = await getActiveTemplate(req.params.templateCode);
|
||||||
|
|
||||||
|
if (!template) {
|
||||||
|
return res.status(404).json({ message: 'Template not found.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json(template);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/:templateCode/versions/:versionNumber',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const template = await getTemplateVersion(
|
||||||
|
req.params.templateCode,
|
||||||
|
req.params.versionNumber
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!template) {
|
||||||
|
return res.status(404).json({ message: 'Template version not found.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json(template);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
+19
-63
@@ -1,73 +1,29 @@
|
|||||||
const express = require("express");
|
import app from './app.js';
|
||||||
const { closePool, executeQuery } = require("./db");
|
import { env } from './config/env.js';
|
||||||
|
import { closePool, query } from './db/pool.js';
|
||||||
|
|
||||||
const app = express();
|
async function startServer() {
|
||||||
const port = Number(process.env.APP_PORT || 3000);
|
await query('SELECT 1 AS ok');
|
||||||
|
|
||||||
app.get("/", (_request, response) => {
|
const server = app.listen(env.port, () => {
|
||||||
response.json({
|
console.log(`Check List PoC API listening on port ${env.port}`);
|
||||||
service: "clproject-env-test",
|
|
||||||
status: "running",
|
|
||||||
endpoints: ["/health", "/db/check"]
|
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
app.get("/health", async (_request, response) => {
|
async function shutdown(signal) {
|
||||||
try {
|
console.log(`Received ${signal}, shutting down...`);
|
||||||
const rows = await executeQuery("SELECT 1 AS connection_ok");
|
server.close(async () => {
|
||||||
|
await closePool();
|
||||||
response.json({
|
process.exit(0);
|
||||||
status: "ok",
|
|
||||||
app: "reachable",
|
|
||||||
database: "reachable",
|
|
||||||
probe: rows[0].connection_ok,
|
|
||||||
timestamp: new Date().toISOString()
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
response.status(503).json({
|
|
||||||
status: "degraded",
|
|
||||||
app: "reachable",
|
|
||||||
database: "unreachable",
|
|
||||||
error: error.message,
|
|
||||||
timestamp: new Date().toISOString()
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
app.get("/db/check", async (_request, response) => {
|
process.on('SIGINT', () => void shutdown('SIGINT'));
|
||||||
try {
|
process.on('SIGTERM', () => void shutdown('SIGTERM'));
|
||||||
const rows = await executeQuery(
|
|
||||||
"SELECT id, name, created_at FROM environment_checks ORDER BY id"
|
|
||||||
);
|
|
||||||
|
|
||||||
response.json({
|
|
||||||
status: "ok",
|
|
||||||
records: rows
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
response.status(503).json({
|
|
||||||
status: "error",
|
|
||||||
error: error.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const server = app.listen(port, () => {
|
|
||||||
console.log(`Server listening on port ${port}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
async function shutdown(signal) {
|
|
||||||
console.log(`Received ${signal}, shutting down`);
|
|
||||||
server.close(async () => {
|
|
||||||
await closePool();
|
|
||||||
process.exit(0);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
process.on("SIGINT", () => {
|
startServer().catch(async (error) => {
|
||||||
shutdown("SIGINT");
|
console.error('Failed to start server');
|
||||||
});
|
console.error(error);
|
||||||
|
await closePool();
|
||||||
process.on("SIGTERM", () => {
|
process.exit(1);
|
||||||
shutdown("SIGTERM");
|
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { query } from '../db/pool.js';
|
||||||
|
import { parseJsonColumn } from '../utils/json.js';
|
||||||
|
|
||||||
|
export async function getImageRules() {
|
||||||
|
const rows = await query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
code,
|
||||||
|
name,
|
||||||
|
allowed_mime_types_json AS allowedMimeTypes,
|
||||||
|
max_file_size_bytes AS maxFileSizeBytes,
|
||||||
|
max_width_px AS maxWidthPx,
|
||||||
|
max_height_px AS maxHeightPx,
|
||||||
|
jpeg_quality AS jpegQuality,
|
||||||
|
oversize_behavior AS oversizeBehavior,
|
||||||
|
max_attachments_per_field AS maxAttachmentsPerField
|
||||||
|
FROM image_rules
|
||||||
|
WHERE is_active = 1
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT 1
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!rows.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...rows[0],
|
||||||
|
allowedMimeTypes: parseJsonColumn(rows[0].allowedMimeTypes, [])
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getExportProfile() {
|
||||||
|
const rows = await query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
code,
|
||||||
|
name,
|
||||||
|
zip_image_dir AS zipImageDir,
|
||||||
|
excel_sheet_name AS excelSheetName,
|
||||||
|
include_template_version AS includeTemplateVersion,
|
||||||
|
include_export_timestamp AS includeExportTimestamp
|
||||||
|
FROM export_profiles
|
||||||
|
WHERE is_active = 1
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT 1
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows.length ? rows[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAppConfig() {
|
||||||
|
const rows = await query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
config_key AS configKey,
|
||||||
|
config_value_json AS configValue
|
||||||
|
FROM app_config
|
||||||
|
ORDER BY config_key ASC
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows.map((row) => ({
|
||||||
|
key: row.configKey,
|
||||||
|
value: parseJsonColumn(row.configValue)
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { query } from '../db/pool.js';
|
||||||
|
|
||||||
|
function groupLookups(rows) {
|
||||||
|
const lookups = new Map();
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!lookups.has(row.lookupCode)) {
|
||||||
|
lookups.set(row.lookupCode, {
|
||||||
|
code: row.lookupCode,
|
||||||
|
name: row.lookupName,
|
||||||
|
values: []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.value !== null) {
|
||||||
|
lookups.get(row.lookupCode).values.push({
|
||||||
|
value: row.value,
|
||||||
|
label: row.label,
|
||||||
|
sortOrder: row.sortOrder,
|
||||||
|
isDefault: Boolean(row.isDefault)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(lookups.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listLookups() {
|
||||||
|
const rows = await query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
ls.code AS lookupCode,
|
||||||
|
ls.name AS lookupName,
|
||||||
|
lv.value,
|
||||||
|
lv.label,
|
||||||
|
lv.sort_order AS sortOrder,
|
||||||
|
lv.is_default AS isDefault
|
||||||
|
FROM lookup_sets ls
|
||||||
|
LEFT JOIN lookup_values lv
|
||||||
|
ON lv.lookup_set_id = ls.id
|
||||||
|
AND lv.is_active = 1
|
||||||
|
WHERE ls.is_active = 1
|
||||||
|
ORDER BY ls.code ASC, lv.sort_order ASC, lv.id ASC
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
return groupLookups(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLookup(lookupCode) {
|
||||||
|
const rows = await query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
ls.code AS lookupCode,
|
||||||
|
ls.name AS lookupName,
|
||||||
|
lv.value,
|
||||||
|
lv.label,
|
||||||
|
lv.sort_order AS sortOrder,
|
||||||
|
lv.is_default AS isDefault
|
||||||
|
FROM lookup_sets ls
|
||||||
|
LEFT JOIN lookup_values lv
|
||||||
|
ON lv.lookup_set_id = ls.id
|
||||||
|
AND lv.is_active = 1
|
||||||
|
WHERE ls.code = ?
|
||||||
|
AND ls.is_active = 1
|
||||||
|
ORDER BY lv.sort_order ASC, lv.id ASC
|
||||||
|
`,
|
||||||
|
[lookupCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!rows.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return groupLookups(rows)[0];
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { query } from '../db/pool.js';
|
||||||
|
import { parseJsonColumn } from '../utils/json.js';
|
||||||
|
|
||||||
|
function mapTemplateRow(row) {
|
||||||
|
return {
|
||||||
|
code: row.code,
|
||||||
|
name: row.name,
|
||||||
|
description: row.description,
|
||||||
|
version: row.versionNumber,
|
||||||
|
status: row.status,
|
||||||
|
publishedAt: row.publishedAt,
|
||||||
|
definition: parseJsonColumn(row.definitionJson)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listTemplates() {
|
||||||
|
const rows = await query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
t.code,
|
||||||
|
t.name,
|
||||||
|
t.description,
|
||||||
|
tv.version_number AS versionNumber,
|
||||||
|
tv.status,
|
||||||
|
tv.published_at AS publishedAt
|
||||||
|
FROM templates t
|
||||||
|
INNER JOIN template_versions tv
|
||||||
|
ON tv.template_id = t.id
|
||||||
|
AND tv.status = 'active'
|
||||||
|
ORDER BY t.name ASC
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows.map((row) => ({
|
||||||
|
code: row.code,
|
||||||
|
name: row.name,
|
||||||
|
description: row.description,
|
||||||
|
activeVersion: row.versionNumber,
|
||||||
|
publishedAt: row.publishedAt
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getActiveTemplate(templateCode) {
|
||||||
|
const rows = await query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
t.code,
|
||||||
|
t.name,
|
||||||
|
t.description,
|
||||||
|
tv.version_number AS versionNumber,
|
||||||
|
tv.status,
|
||||||
|
tv.published_at AS publishedAt,
|
||||||
|
tv.definition_json AS definitionJson
|
||||||
|
FROM templates t
|
||||||
|
INNER JOIN template_versions tv
|
||||||
|
ON tv.template_id = t.id
|
||||||
|
AND tv.status = 'active'
|
||||||
|
WHERE t.code = ?
|
||||||
|
LIMIT 1
|
||||||
|
`,
|
||||||
|
[templateCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows.length ? mapTemplateRow(rows[0]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTemplateVersion(templateCode, versionNumber) {
|
||||||
|
const rows = await query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
t.code,
|
||||||
|
t.name,
|
||||||
|
t.description,
|
||||||
|
tv.version_number AS versionNumber,
|
||||||
|
tv.status,
|
||||||
|
tv.published_at AS publishedAt,
|
||||||
|
tv.definition_json AS definitionJson
|
||||||
|
FROM templates t
|
||||||
|
INNER JOIN template_versions tv
|
||||||
|
ON tv.template_id = t.id
|
||||||
|
WHERE t.code = ?
|
||||||
|
AND tv.version_number = ?
|
||||||
|
LIMIT 1
|
||||||
|
`,
|
||||||
|
[templateCode, Number(versionNumber)]
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows.length ? mapTemplateRow(rows[0]) : null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export function asyncHandler(handler) {
|
||||||
|
return async function wrappedHandler(req, res, next) {
|
||||||
|
try {
|
||||||
|
await handler(req, res, next);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export function parseJsonColumn(value, fallback = null) {
|
||||||
|
if (value == null) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(value);
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user