diff --git a/.env.example b/.env.example index 4e6810c..2f19855 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,10 @@ -APP_PORT=3000 +PORT=3000 APP_URL=http://localhost:3000 DB_HOST=db DB_PORT=3306 -DB_NAME=app_db -DB_USER=app_user -DB_PASSWORD=app_password +DB_NAME=check_list +DB_USER=check_list_user +DB_PASSWORD=check_list_password +DB_CONNECTION_LIMIT=5 MARIADB_ROOT_PASSWORD=change_me_for_local_dev -PHPMYADMIN_PORT=8080 \ No newline at end of file +PHPMYADMIN_PORT=8080 diff --git a/.gitignore b/.gitignore index dbc310d..b092cf4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ +node_modules/ .env -node_modules .env.local -npm-debug.log \ No newline at end of file +dist/ +npm-debug.log diff --git a/README.md b/README.md index 7ad6c75..8570bc8 100644 --- a/README.md +++ b/README.md @@ -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` -- `db`: MariaDB container built from `bitnami/mariadb` -- `phpmyadmin`: phpMyAdmin container for database inspection +- Node.js REST API for template and configuration delivery +- MariaDB schema for phase 1 configuration data +- 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. -2. Run `Dev Containers: Reopen in Container`. -3. Wait for the `app` service to install dependencies and start the server. +Included: +- template list endpoint +- 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` - 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 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 -- the app can talk to MariaDB -- the seed table was created successfully +## API endpoints -## Database Login +### Service health -- Server: `db` -- Database: `app_db` -- User: `app_user` -- Password: `app_password` +`GET /api/health` -Root password is configured in `.env` for local development. \ No newline at end of file +### 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. \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index d04b50e..5c8986b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,28 +7,38 @@ services: - .env working_dir: /workspace 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: - .:/workspace:cached ports: - - "${APP_PORT:-3000}:${APP_PORT:-3000}" + - "${PORT:-3000}:${PORT:-3000}" depends_on: - - db + db: + condition: service_healthy + restart: unless-stopped db: image: bitnami/mariadb:latest env_file: - .env environment: - MARIADB_DATABASE: ${DB_NAME:-app_db} - MARIADB_USER: ${DB_USER:-app_user} - MARIADB_PASSWORD: ${DB_PASSWORD:-app_password} + MARIADB_DATABASE: ${DB_NAME:-check_list} + MARIADB_USER: ${DB_USER:-check_list_user} + MARIADB_PASSWORD: ${DB_PASSWORD:-check_list_password} MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD:-root_password} ports: - "${DB_PORT:-3306}:3306" volumes: - 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: image: phpmyadmin:5-apache @@ -37,8 +47,8 @@ services: environment: PMA_HOST: db PMA_PORT: 3306 - PMA_USER: ${DB_USER:-app_user} - PMA_PASSWORD: ${DB_PASSWORD:-app_password} + PMA_USER: ${DB_USER:-check_list_user} + PMA_PASSWORD: ${DB_PASSWORD:-check_list_password} ports: - "${PHPMYADMIN_PORT:-8080}:80" diff --git a/docker/mariadb/init/01-bootstrap.sql b/docker/mariadb/init/01-bootstrap.sql deleted file mode 100644 index ca2e648..0000000 --- a/docker/mariadb/init/01-bootstrap.sql +++ /dev/null @@ -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); \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index fda415a..06b7e4b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,20 @@ { - "name": "clproject-env-test", - "version": "1.0.0", + "name": "check-list-poc-api", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "clproject-env-test", - "version": "1.0.0", + "name": "check-list-poc-api", + "version": "0.1.0", "dependencies": { - "dotenv": "^16.4.7", + "cors": "^2.8.5", + "dotenv": "^16.4.5", "express": "^4.21.2", "mariadb": "^3.4.0" + }, + "engines": { + "node": ">=20.0.0" } }, "node_modules/@types/geojson": { @@ -145,6 +149,23 @@ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "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": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -601,6 +622,15 @@ "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": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", diff --git a/package.json b/package.json index f60180c..a377b0b 100644 --- a/package.json +++ b/package.json @@ -1,16 +1,20 @@ { - "name": "clproject-env-test", - "version": "1.0.0", - "private": true, - "description": "Containerized Node.js + MariaDB development environment smoke test", + "name": "check-list-poc-api", + "version": "0.1.0", + "description": "Proof-of-concept backend for the Check List hybrid quality reporting solution.", + "type": "module", "main": "src/server.js", "scripts": { - "dev": "node --watch src/server.js", "start": "node src/server.js", + "dev": "node --watch src/server.js", "test:environment": "node scripts/test-environment.js" }, + "engines": { + "node": ">=20.0.0" + }, "dependencies": { - "dotenv": "^16.4.7", + "cors": "^2.8.5", + "dotenv": "^16.4.5", "express": "^4.21.2", "mariadb": "^3.4.0" } diff --git a/scripts/test-environment.js b/scripts/test-environment.js index e83f643..73dd906 100644 --- a/scripts/test-environment.js +++ b/scripts/test-environment.js @@ -1,44 +1,50 @@ -require("dotenv").config(); +import 'dotenv/config'; -const assert = require("node:assert/strict"); -const http = require("node:http"); -const https = require("node:https"); -const mariadb = require("mariadb"); +import assert from 'node:assert/strict'; +import http from 'node:http'; +import https from 'node:https'; + +import * as mariadb from 'mariadb'; function unique(values) { return [...new Set(values.filter(Boolean))]; } -function getJson(targetUrl) { +function requestUrl(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) => { const request = transport.get(url, (response) => { - let body = ""; + let body = ''; - response.on("data", (chunk) => { + response.on('data', (chunk) => { body += chunk; }); - response.on("end", () => { - try { - resolve({ - body: JSON.parse(body), - statusCode: response.statusCode - }); - } catch (error) { - reject(error); - } + response.on('end', () => { + resolve({ + body, + statusCode: response.statusCode + }); }); }); - request.on("error", reject); + request.on('error', reject); }); } -async function testApp() { - const appPort = Number(process.env.APP_PORT || 3000); +async function getJson(targetUrl) { + 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([ process.env.APP_URL, `http://localhost:${appPort}`, @@ -49,13 +55,24 @@ async function testApp() { for (const baseUrl of candidates) { 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(response.body.status, "ok", "Application health endpoint is not healthy"); - assert.equal(response.body.database, "reachable", "Application cannot reach MariaDB"); + assert.equal(health.statusCode, 200, `Unexpected health status from ${baseUrl}`); + assert.equal(health.json.status, 'ok', 'API health endpoint is not healthy'); + 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) { lastError = error; } @@ -67,9 +84,9 @@ async function testApp() { async function testDatabase() { const candidates = unique([ process.env.DB_HOST, - "127.0.0.1", - "localhost", - "db" + '127.0.0.1', + 'localhost', + 'db' ]); let lastError; @@ -78,22 +95,22 @@ async function testDatabase() { const pool = mariadb.createPool({ host, 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", + user: process.env.DB_USER || 'check_list_user', + password: process.env.DB_PASSWORD || 'check_list_password', + database: process.env.DB_NAME || 'check_list', connectionLimit: 1 }); try { const connection = await pool.getConnection(); const rows = await connection.query( - "SELECT name FROM environment_checks ORDER BY id" + 'SELECT code FROM templates ORDER BY id' ); connection.release(); 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 }; } catch (error) { @@ -105,18 +122,44 @@ async function testDatabase() { throw lastError; } -async function main() { - const appResult = await testApp(); - const databaseResult = await testDatabase(); +async function testPhpMyAdmin() { + const candidates = unique([ + 'http://phpmyadmin/', + `http://localhost:${process.env.PHPMYADMIN_PORT || 8080}/` + ]); - console.log("Environment test passed"); - console.log(`App endpoint: ${appResult.baseUrl}`); + let lastError; + + 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(`Seed rows: ${databaseResult.rows.length}`); + console.log(`phpMyAdmin: ${phpMyAdminResult.url}`); } main().catch((error) => { - console.error("Environment test failed"); + console.error('Environment test failed'); console.error(error.message); process.exit(1); }); \ No newline at end of file diff --git a/sql/schema.sql b/sql/schema.sql new file mode 100644 index 0000000..ca3cd5e --- /dev/null +++ b/sql/schema.sql @@ -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) +); diff --git a/sql/seed.sql b/sql/seed.sql new file mode 100644 index 0000000..984b084 --- /dev/null +++ b/sql/seed.sql @@ -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); diff --git a/src/app.js b/src/app.js new file mode 100644 index 0000000..fbb8395 --- /dev/null +++ b/src/app.js @@ -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; diff --git a/src/config/env.js b/src/config/env.js new file mode 100644 index 0000000..acab882 --- /dev/null +++ b/src/config/env.js @@ -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) + } +}; diff --git a/src/db.js b/src/db.js deleted file mode 100644 index 17d2b10..0000000 --- a/src/db.js +++ /dev/null @@ -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 -}; \ No newline at end of file diff --git a/src/db/pool.js b/src/db/pool.js new file mode 100644 index 0000000..756677c --- /dev/null +++ b/src/db/pool.js @@ -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(); +} diff --git a/src/middleware/errorHandler.js b/src/middleware/errorHandler.js new file mode 100644 index 0000000..308668f --- /dev/null +++ b/src/middleware/errorHandler.js @@ -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.' + }); +} diff --git a/src/routes/configRoutes.js b/src/routes/configRoutes.js new file mode 100644 index 0000000..60fe7ee --- /dev/null +++ b/src/routes/configRoutes.js @@ -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; diff --git a/src/routes/healthRoutes.js b/src/routes/healthRoutes.js new file mode 100644 index 0000000..a7dfcc0 --- /dev/null +++ b/src/routes/healthRoutes.js @@ -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; diff --git a/src/routes/lookupRoutes.js b/src/routes/lookupRoutes.js new file mode 100644 index 0000000..63b8cf2 --- /dev/null +++ b/src/routes/lookupRoutes.js @@ -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; diff --git a/src/routes/templateRoutes.js b/src/routes/templateRoutes.js new file mode 100644 index 0000000..11bb37c --- /dev/null +++ b/src/routes/templateRoutes.js @@ -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; diff --git a/src/server.js b/src/server.js index 755dea5..f1b38ba 100644 --- a/src/server.js +++ b/src/server.js @@ -1,73 +1,29 @@ -const express = require("express"); -const { closePool, executeQuery } = require("./db"); +import app from './app.js'; +import { env } from './config/env.js'; +import { closePool, query } from './db/pool.js'; -const app = express(); -const port = Number(process.env.APP_PORT || 3000); +async function startServer() { + await query('SELECT 1 AS ok'); -app.get("/", (_request, response) => { - response.json({ - service: "clproject-env-test", - status: "running", - endpoints: ["/health", "/db/check"] + const server = app.listen(env.port, () => { + console.log(`Check List PoC API listening on port ${env.port}`); }); -}); -app.get("/health", async (_request, response) => { - try { - const rows = await executeQuery("SELECT 1 AS connection_ok"); - - response.json({ - 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() + async function shutdown(signal) { + console.log(`Received ${signal}, shutting down...`); + server.close(async () => { + await closePool(); + process.exit(0); }); } -}); -app.get("/db/check", async (_request, response) => { - try { - 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', () => void shutdown('SIGINT')); + process.on('SIGTERM', () => void shutdown('SIGTERM')); } -process.on("SIGINT", () => { - shutdown("SIGINT"); +startServer().catch(async (error) => { + console.error('Failed to start server'); + console.error(error); + await closePool(); + process.exit(1); }); - -process.on("SIGTERM", () => { - shutdown("SIGTERM"); -}); \ No newline at end of file diff --git a/src/services/configService.js b/src/services/configService.js new file mode 100644 index 0000000..dc86350 --- /dev/null +++ b/src/services/configService.js @@ -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) + })); +} diff --git a/src/services/lookupService.js b/src/services/lookupService.js new file mode 100644 index 0000000..7c5acf0 --- /dev/null +++ b/src/services/lookupService.js @@ -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]; +} diff --git a/src/services/templateService.js b/src/services/templateService.js new file mode 100644 index 0000000..bbf6adf --- /dev/null +++ b/src/services/templateService.js @@ -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; +} diff --git a/src/utils/asyncHandler.js b/src/utils/asyncHandler.js new file mode 100644 index 0000000..1f4c708 --- /dev/null +++ b/src/utils/asyncHandler.js @@ -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); + } + }; +} diff --git a/src/utils/json.js b/src/utils/json.js new file mode 100644 index 0000000..1700f92 --- /dev/null +++ b/src/utils/json.js @@ -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; + } +}