This commit is contained in:
Stan
2026-04-19 21:14:16 +02:00
parent 0c74a75126
commit 28d167f11f
42 changed files with 5681 additions and 55 deletions
+676
View File
@@ -0,0 +1,676 @@
# Project Files Guide
This document explains the role of the main files in this project in a way that should be easy to follow for a junior developer.
The project is split into a few clear parts:
- server code in [src/app.js](src/app.js) and the rest of [src](src)
- browser frontend files in [public](public)
- database structure and example data in [sql](sql)
- environment and local development helpers in the root folder and [scripts](scripts)
## How The Application Works At A High Level
The project has two big sides:
- the backend, which provides templates, lookups, configuration, report storage, and audit logging through REST API endpoints
- the frontend, which runs in the browser, stores drafts locally, and uses the backend for centrally managed configuration and report submission
In simple terms:
1. Express starts the server.
2. The server exposes API endpoints under `/api/v1/...`.
3. The server also serves the frontend files from the [public](public) folder.
4. The browser loads the frontend entry point [public/app.js](public/app.js) which imports modules from [public/js/](public/js).
5. The frontend downloads templates and config from the API using a single batch request.
6. The frontend stores reports and images locally in IndexedDB.
7. Reports can be submitted to the server via `POST /api/v1/reports`.
## Root Files
### [package.json](package.json)
Role:
Defines the Node.js project itself.
What it contains:
- project name and version
- npm scripts such as `start`, `dev`, and `test:environment`
- dependency list such as `express`, `mariadb`, and `dotenv`
Why it matters:
When you run `npm install` or `npm start`, Node uses this file to know how the project should behave.
### [README.md](README.md)
Role:
Main entry document for developers.
What it contains:
- project purpose
- setup steps
- available endpoints
- development notes
Why it matters:
This is the first file a new developer should read before touching the code.
### [docker-compose.yml](docker-compose.yml)
Role:
Starts the full local development environment.
What it contains:
- the application container
- the MariaDB container
- the phpMyAdmin container
- port mappings and volume configuration
Why it matters:
Instead of installing and configuring everything manually, you can start the full stack with one command.
### [.env.example](.env.example)
Role:
Example environment configuration.
What it contains:
- default port values
- database host, name, user, and password placeholders
Why it matters:
It shows which environment variables the application expects.
### [.env](.env)
Role:
Real environment file used on the current machine.
What it contains:
- actual local values for ports and database credentials
Why it matters:
The backend reads this file during startup through `dotenv`.
## Public Folder
The [public](public) folder contains files that are sent directly to the browser.
### [public/user.html](public/user.html)
Role:
Operator workspace HTML shell.
What it contains:
- shared sidebar with template selector, sync button, report list, and search/filter
- report editor main area with hero, summary cards, form, inspector panel
- report list item `<template>` for runtime rendering
When you edit it:
- when you need to change the report editing layout
- when adding new user-facing controls or sections
### [public/admin.html](public/admin.html)
Role:
Administrator workspace HTML shell.
What it contains:
- simplified sidebar with template selector and sync button (no report list)
- admin main area with image policy editor form and admin summary panel
When you edit it:
- when adding new admin configuration panels
- when changing the image policy form fields
### [public/index.html](public/index.html)
Role:
Legacy combined app shell (no longer served by routes).
Note:
This file has been superseded by [user.html](public/user.html) and [admin.html](public/admin.html).
It remains in the repository for reference but is not routed to by the Express server.
### [public/portal.html](public/portal.html)
Role:
Simple chooser page that lets the user open either the user area or the admin area.
What it contains:
- a short explanation of the two entry points
- a link to `/user`
- a link to `/admin`
What it does:
It is the landing page served at `/`. It does not contain complex logic. Its job is only to separate the entry points.
### [public/app.js](public/app.js)
Role:
Thin orchestrator entry point for the frontend.
What it contains:
- imports from all ES modules under `public/js/`
- startup flow (`init()`, `cacheElements()`, `bindEvents()`)
- report CRUD operations (create, open, delete, submit)
- sync logic with the API using batch template fetch
- dirty-flag autosave loop
- admin image-policy save
- event delegation for the report list
- search/filter event wiring
- null guards for page-specific elements (user vs admin)
What changed:
This file was originally a monolithic ~1700-line file. It has been split into modules (A1)
and now acts as a controller. It is shared between user.html and admin.html — it detects
which DOM elements exist and only binds behavior for the current page.
### [public/styles.css](public/styles.css)
Role:
Main styling file for the frontend.
What it contains:
- color variables
- layout rules
- sidebar and panel styles
- form field styles
- admin and user page styles
- responsive behavior for smaller screens
What it does:
It controls how the application looks on desktop and mobile.
When you edit it:
- when spacing or sizing is wrong
- when a section needs a new visual style
- when mobile layout needs improvement
### [public/sw.js](public/sw.js)
Role:
Service worker for offline behavior.
What it contains:
- cache name
- app shell file list (including all new JS modules)
- install, activate, and fetch event handlers
- LRU cache trimming for dynamic entries (P5)
What it does:
It tells the browser how to cache frontend files and API responses.
Important behavior:
- static files use cache-first strategy
- API calls use network-first strategy
- dynamic cache is bounded to prevent unbounded storage growth
### [public/icon.svg](public/icon.svg)
Role:
SVG icon used by the PWA manifest (F5).
### Frontend ES Modules (public/js/)
These modules were extracted from the original monolithic `app.js` (A1).
All render functions include null guards so the shared `app.js` can safely run on
both user.html (operator workspace) and admin.html (administrator workspace).
### [public/js/constants.js](public/js/constants.js)
Role:
Shared constants used across frontend modules.
What it contains:
- IndexedDB name, version, and store names
- API base path (`/api/v1`)
- autosave interval, render debounce timing, cache limits
### [public/js/state.js](public/js/state.js)
Role:
Centralized state management for the frontend.
What it contains:
- `state` object with all application state (including dirty flag, search query, filter)
- `elements` object for cached DOM references
- `getCurrentReport()` and `getTemplateRecord()` helpers
- `stateHelpers` bundle used by other modules to avoid circular imports
### [public/js/i18n.js](public/js/i18n.js)
Role:
Internationalization locale extraction (F7).
What it contains:
- English locale with ~80 UI string keys
- `t(key, ...params)` translation function with placeholder replacement
- `setLocale()` for future language support
### [public/js/utils.js](public/js/utils.js)
Role:
Pure utility and formatting functions.
What it contains:
- `prettifyStatus()`, `formatDateTime()`, `formatTime()`, `formatRelativeTime()`, `formatFileSize()`
- `sanitizeForFilename()`, `generateReportNumber()`, `buildGeneratedFilename()`
- `makeTemplateKey()`, `deriveTemplateCatalog()`
- `debounce()` helper for rate-limiting function calls (P2)
### [public/js/db.js](public/js/db.js)
Role:
IndexedDB operations with multi-store transaction support.
What it contains:
- `openDatabase()` with schema version 2
- CRUD helpers: `dbGetAll()`, `dbGet()`, `dbPut()`, `dbDelete()`, `dbGetAllByIndex()`
- `dbTransaction()` for atomic multi-store operations (A5)
- `saveSetting()` and `loadSetting()` for UI preferences
### [public/js/api.js](public/js/api.js)
Role:
API communication layer with versioned base path (A3).
What it contains:
- `fetchJson(path, options)` that prepends the API base path automatically
- `registerServiceWorker()`
### [public/js/validation.js](public/js/validation.js)
Role:
Shared validation logic for reports and admin forms (A7).
What it contains:
- `validateReport()` — checks required fields, number ranges, attachment counts
- `validateImageRulesPayload()` — validates image policy form submissions
- `evaluateRequiredWhen()` — conditional field requirement logic
- `isBlankValue()` — blank check by field type
### [public/js/images.js](public/js/images.js)
Role:
Image optimization with Web Worker support (P1).
What it contains:
- `optimizeImage(file, imageRules)` — automatically detects Web Worker support
- Worker path: delegates to `image-worker.js` via `OffscreenCanvas`
- Falls back to main-thread canvas when OffscreenCanvas is unavailable
### [public/js/image-worker.js](public/js/image-worker.js)
Role:
Web Worker for background image processing (P1).
What it contains:
- `self.onmessage` handler using `createImageBitmap` and `OffscreenCanvas`
- Sends optimized blob back to main thread with matching message ID
### [public/js/forms.js](public/js/forms.js)
Role:
Dynamic form field creation from template JSON.
What it contains:
- `createFieldNode(field, report, callbacks)` — renders fields with callback pattern
- `createAttachmentFieldNode()` with IntersectionObserver for lazy-loading thumbnails (P3)
- `escapeHtml()` for XSS prevention
### [public/js/renderer.js](public/js/renderer.js)
Role:
All DOM render functions with search and filter support (F4).
What it contains:
- `render()` orchestrator — conditionally calls user or admin renders
- `renderReportList()` — filters by search query and status
- `renderCurrentReport(fieldCallbacks)` — accepts callbacks for field changes
- `renderTemplateSelector()`, `renderTemplateSummary()`
- `renderSyncSummary()`, `renderImagePolicy()`, `renderAdminImageRules()`
- `updateConnectionBadge()`, `updateSaveBadge()`
- Uses `t()` for all user-visible strings
- Null guards on all page-specific functions for safe user/admin coexistence
### [public/js/export.js](public/js/export.js)
Role:
Report data export functionality (F2).
What it contains:
- `exportReportCSV()` — generates CSV from template fields and report answers
- `exportReportAttachments()` — triggers download for each attachment blob
- `csvEscape()` and `downloadBlob()` helper functions
### [public/manifest.webmanifest](public/manifest.webmanifest)
Role:
Basic PWA metadata file.
What it contains:
- app name
- short name
- theme color
- start URL
What it does:
Helps the browser treat the app more like an installable web application.
## Source Folder
The [src](src) folder contains backend code.
### [src/server.js](src/server.js)
Role:
Real server startup file.
What it contains:
- startup function
- database connection check
- Express app startup
- graceful shutdown logic
What it does:
This is the file Node runs when the backend starts.
Simple mental model:
- load the Express app
- make sure MariaDB is reachable
- start listening on a port
- close cleanly when the process is stopped
### [src/app.js](src/app.js)
Role:
Express application configuration file.
What it contains:
- middleware registration
- API v1 route registration under `/api/v1/` prefix (A3)
- static file serving
- frontend entry page routes
- global error handling
Important routes:
- `/api/v1/...` for versioned backend endpoints
- `/` for the chooser page
- `/user` and `/admin` for the frontend app shell
## Source Subfolders
### [src/config/env.js](src/config/env.js)
Role:
Loads and validates environment variables.
What it contains:
- `dotenv` setup
- required environment key checks
- normalized `env` object export
What it does:
It reads raw values from `.env` and converts them into a safer object the rest of the code can use.
Why this is useful:
Instead of reading `process.env` everywhere, the app reads from one clean place.
### [src/db/pool.js](src/db/pool.js)
Role:
Creates and manages the MariaDB connection pool.
What it contains:
- one shared database pool
- `query()` helper
- `closePool()` helper
What it does:
It gives the service layer a standard way to run SQL queries.
Why it matters:
Without this file, each route or service would have to manage its own connections, which would be messy and error-prone.
### [src/middleware/errorHandler.js](src/middleware/errorHandler.js)
Role:
Handles not-found routes and server errors.
What it contains:
- `notFoundHandler`
- `errorHandler`
What it does:
When a route does not exist, or when code throws an error, this file creates a JSON response instead of letting the app fail in an uncontrolled way.
### [src/middleware/validateParams.js](src/middleware/validateParams.js)
Role:
URL parameter validation middleware (A7).
What it contains:
- `validateParam(name)` — checks route params against safe patterns (code or UUID)
- `validateNumericParam(name)` — validates numeric route parameters
What it does:
It rejects malformed URL parameters at the middleware level before they reach route handlers.
### [src/routes/healthRoutes.js](src/routes/healthRoutes.js)
Role:
Defines the health-check endpoint.
What it contains:
- `GET /api/health`
What it does:
Checks whether the server and database are working.
Why it matters:
This endpoint is used by tests and is also useful when debugging container problems.
### [src/routes/templateRoutes.js](src/routes/templateRoutes.js)
Role:
Defines template-related API endpoints.
What it contains:
- list endpoint for active templates
- batch endpoint with `?include=definitions` for single-request sync (A2)
- endpoint for one active template
- endpoint for a specific template version
- version listing for a template (F3)
- version publishing endpoint (F3)
- parameter validation middleware
- LRU cache integration (P4)
### [src/routes/lookupRoutes.js](src/routes/lookupRoutes.js)
Role:
Defines lookup-related API endpoints.
What it contains:
- list endpoint for lookup sets
- endpoint for one lookup set
- parameter validation and LRU cache integration
### [src/routes/configRoutes.js](src/routes/configRoutes.js)
Role:
Defines configuration-related API endpoints.
What it contains:
- image rule read and update endpoints
- export profile endpoint
- general app config endpoint
- LRU cache read-through and invalidation (P4)
- audit trail logging on image rules update (F6)
### [src/routes/reportRoutes.js](src/routes/reportRoutes.js)
Role:
Defines report submission API endpoints (F1).
What it contains:
- `GET /` — list submitted reports with filters (status, templateCode, limit, offset)
- `GET /:reportId` — single report by UUID
- `POST /` — submit or update a report (UPSERT by UUID, audit-logged)
### [src/services/templateService.js](src/services/templateService.js)
Role:
Handles template-related database queries.
What it contains:
- SQL for active template list
- `getAllActiveTemplates()` — batch query returning all templates with definitions (A2)
- `listTemplateVersions()` — all versions of a given template (F3)
- `publishTemplateVersion()` — retire current active, activate specified version (F3)
- template row mapping logic
### [src/services/lookupService.js](src/services/lookupService.js)
Role:
Handles lookup-related database queries.
### [src/services/configService.js](src/services/configService.js)
Role:
Handles configuration-related database queries.
### [src/services/reportService.js](src/services/reportService.js)
Role:
Handles report submission and retrieval (F1).
What it contains:
- `submitReport(report)` — UPSERT by report UUID
- `getReport(uuid)` — fetch single report
- `listReports({ status, templateCode, limit, offset })` — filtered listing
### [src/services/auditService.js](src/services/auditService.js)
Role:
Audit trail logging for admin mutations (F6).
What it contains:
- `logAuditEvent({ entityType, entityCode, action, oldValue, newValue })` — writes to audit_log table
- `getAuditLog()` — reads audit entries with optional filters
### [src/services/cacheService.js](src/services/cacheService.js)
Role:
In-memory LRU cache with TTL for server-side data (P4).
What it contains:
- `createCache({ maxEntries, ttlMs })` factory function
- Pre-configured singletons: `templateCache`, `lookupCache`, `configCache`
- Each cache instance has `get()`, `set()`, `delete()`, and `clear()` methods
### [src/utils/asyncHandler.js](src/utils/asyncHandler.js)
Role:
Small helper for async Express routes.
What it contains:
- `asyncHandler()` wrapper
What it does:
It catches rejected async route errors and forwards them to Express error middleware.
Why it matters:
Without it, every async route would need repetitive `try/catch` blocks.
### [src/utils/json.js](src/utils/json.js)
Role:
Small helper for parsing JSON values coming from MariaDB.
What it contains:
- `parseJsonColumn()`
What it does:
It safely converts JSON strings into objects and arrays.
Why it matters:
Several database columns store JSON text, and this helper prevents the same parsing logic from being repeated in multiple files.
## SQL Folder
### [sql/schema.sql](sql/schema.sql)
Role:
Creates the database structure.
What it contains:
- database creation
- table definitions
- keys and foreign keys
What it does:
It defines how templates, lookups, image rules, export profiles, and app config are stored.
### [sql/seed.sql](sql/seed.sql)
Role:
Inserts example data.
What it contains:
- one example checklist template
- lookup values
- one image policy
- one export profile
- app config values
What it does:
It gives the frontend and API something real to work with immediately after startup.
## Scripts Folder
### [scripts/test-environment.js](scripts/test-environment.js)
Role:
Simple smoke test for the local environment.
What it contains:
- API health checks
- template endpoint checks
- direct database checks
- phpMyAdmin checks
What it does:
It verifies that the main development services are working together.
When to use it:
- after starting Docker containers
- after changing infrastructure-related code
- after big refactors when you want a quick confidence check
## Recommended Reading Order For A Junior Developer
If you are new to this project, a good reading order is:
1. [README.md](README.md)
2. [PROJECT_FILES_GUIDE.md](PROJECT_FILES_GUIDE.md)
3. [src/server.js](src/server.js)
4. [src/app.js](src/app.js)
5. [src/routes/templateRoutes.js](src/routes/templateRoutes.js)
6. [src/services/templateService.js](src/services/templateService.js)
7. [public/js/constants.js](public/js/constants.js) and [public/js/state.js](public/js/state.js)
8. [public/app.js](public/app.js) — the thin orchestrator that wires everything together
9. [public/js/renderer.js](public/js/renderer.js) and [public/js/forms.js](public/js/forms.js)
7. [public/index.html](public/index.html)
8. [public/app.js](public/app.js)
9. [sql/schema.sql](sql/schema.sql)
10. [sql/seed.sql](sql/seed.sql)
That order helps because it moves from the high-level entry points into the deeper details.
## Practical Rule Of Thumb
When you are trying to change something, use this shortcut:
- if the browser layout looks wrong, check [public/index.html](public/index.html) and [public/styles.css](public/styles.css)
- if browser behavior is wrong, check [public/app.js](public/app.js)
- if an API endpoint is wrong, check [src/routes](src/routes) first and then [src/services](src/services)
- if database data is wrong, check [sql/schema.sql](sql/schema.sql), [sql/seed.sql](sql/seed.sql), and the service file that runs the query
- if the app does not start, check [src/server.js](src/server.js), [src/config/env.js](src/config/env.js), and [docker-compose.yml](docker-compose.yml)
This simple rule is often enough to help you find the right file quickly.
+133 -38
View File
@@ -1,34 +1,44 @@
# Check List Proof of Concept
This repository contains a minimal proof-of-concept backend for the Check List hybrid reporting solution described in the project documentation.
This repository contains a proof-of-concept implementation for the Check List hybrid reporting solution described in the project documentation.
## What is included
- Node.js REST API for template and configuration delivery
- MariaDB schema for phase 1 configuration data
- Node.js REST API (v1) for template, configuration, report, and audit delivery
- static frontend PoC served by Express, split into focused ES modules
- MariaDB schema for configuration data, submitted reports, and audit trail
- 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
- service worker with bounded LRU cache for offline support
## Scope of this PoC
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
- all endpoints under versioned `/api/v1/` prefix
- batch template endpoint with `?include=definitions` for single-request sync
- template version listing and publishing management
- lookup endpoints with parameter validation
- image rule endpoint with server-side LRU cache and audit trail
- export profile and generic application config endpoints
- report submission endpoint (POST) with UPSERT
- audit log recording for admin mutations
- offline-capable frontend shell split into ES modules
- IndexedDB-based local drafts with multi-store transactions
- dynamic form rendering from template JSON
- local attachment storage with Web Worker image optimization
- report search and status filter
- CSV export for report data
- i18n locale extraction for UI strings
- PWA manifest with SVG icon
- debounced renders and dirty-flag autosave
Not included:
- report upload
- authentication
- admin UI
- report draft storage backend
- XLSX or ZIP generation
- client-side offline application
- authentication and authorization
- file attachment upload to server (binary upload requires multer)
- XLSX or ZIP generation (CSV is provided; advanced formats require library vendoring)
- production-grade frontend bundling
- automated test suite
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.
@@ -39,6 +49,29 @@ The PoC keeps template content inside a JSON column to reduce initial complexity
├── .devcontainer/
├── docker-compose.yml
├── package.json
├── public/
│ ├── admin.html ← administrator workspace
│ ├── app.js ← thin orchestrator entry point
│ ├── icon.svg ← PWA icon
│ ├── index.html ← legacy combined shell (unused)
│ ├── manifest.webmanifest
│ ├── portal.html
│ ├── styles.css
│ ├── sw.js
│ ├── user.html ← operator workspace
│ └── js/
│ ├── api.js ← API communication (versioned base path)
│ ├── constants.js ← shared constants (DB, API, limits)
│ ├── db.js ← IndexedDB operations, multi-store tx
│ ├── export.js ← CSV export and attachment download
│ ├── forms.js ← dynamic form field creation
│ ├── i18n.js ← English locale, t() translation
│ ├── image-worker.js ← OffscreenCanvas Web Worker
│ ├── images.js ← image optimization with worker fallback
│ ├── renderer.js ← all render functions with search/filter
│ ├── state.js ← centralized state management
│ ├── utils.js ← utility functions
│ └── validation.js ← shared validation (client-side)
├── scripts/
│ └── test-environment.js
├── sql/
@@ -48,13 +81,34 @@ The PoC keeps template content inside a JSON column to reduce initial complexity
├── app.js
├── server.js
├── config/
│ └── env.js
├── db/
│ └── pool.js
├── middleware/
│ ├── errorHandler.js
│ └── validateParams.js ← URL parameter validation
├── routes/
│ ├── configRoutes.js
│ ├── healthRoutes.js
│ ├── lookupRoutes.js
│ ├── reportRoutes.js ← report submission endpoints
│ └── templateRoutes.js
├── services/
│ ├── auditService.js ← audit trail logging
│ ├── cacheService.js ← in-memory LRU cache with TTL
│ ├── configService.js
│ ├── lookupService.js
│ ├── reportService.js ← report CRUD operations
│ └── templateService.js
└── utils/
├── asyncHandler.js
└── json.js
```
## File guide
For a junior-friendly explanation of what each main file does, see [PROJECT_FILES_GUIDE.md](PROJECT_FILES_GUIDE.md).
## Run with Docker and Dev Containers
1. Copy `.env.example` to `.env` if you want custom local credentials.
@@ -87,37 +141,87 @@ npm run test:environment
```
The test verifies:
- the API health endpoint
- seeded template data via `/api/templates`
- the API health endpoint at `/api/v1/health`
- seeded template data via `/api/v1/templates`
- direct MariaDB connectivity
- phpMyAdmin availability
## Frontend PoC
Open `http://localhost:3000` after the stack is running.
Entry points:
- `http://localhost:3000/` opens the chooser portal
- `http://localhost:3000/user` opens the user workspace directly
- `http://localhost:3000/admin` opens the administrator workspace directly
The frontend demonstrates:
- template sync from the API via batch endpoint
- offline cache via IndexedDB and service worker (LRU-bounded)
- local report creation, switching, search, and filtering
- dynamic form rendering based on the seeded template
- local attachment storage and preview with lazy-loading
- Web Worker image optimization (OffscreenCanvas) with main-thread fallback
- debounced re-renders and dirty-flag autosave
- report submission to backend
- CSV export for report data and individual attachment downloads
- administrator mode for editing image requirements with audit trail
- i18n-ready UI strings extracted to locale file
This frontend is intentionally lightweight and framework-free so the proof of concept stays easy to inspect and adapt. The monolithic app.js has been split into focused ES modules under `public/js/`, and the UI is served as two separate HTML shells (`user.html` for operators, `admin.html` for administrators) sharing a single `app.js` orchestrator with null-guarded element access.
## Administrator mode
Open `http://localhost:3000/admin` or use the chooser page at `http://localhost:3000/`.
The current PoC administrator flow supports:
- policy name changes
- allowed MIME types
- maximum file size
- maximum width and height
- JPEG quality
- oversize behavior
- maximum attachments per field
The frontend saves these values to the backend through `PUT /api/v1/config/image-rules` and refreshes the local cache after save. All admin mutations are recorded in the audit log.
## API endpoints
All endpoints are under the `/api/v1/` prefix.
### Service health
`GET /api/health`
`GET /api/v1/health`
### Templates
- `GET /api/templates`
- `GET /api/templates/incoming-inspection`
- `GET /api/templates/incoming-inspection/versions/1`
- `GET /api/v1/templates` — list active templates
- `GET /api/v1/templates?include=definitions` — batch: all templates with definitions in one request
- `GET /api/v1/templates/:code` — single active template with definition
- `GET /api/v1/templates/:code/versions` — list all versions of a template
- `PUT /api/v1/templates/:code/versions/:versionNumber/publish` — publish a specific template version
### Lookups
- `GET /api/lookups`
- `GET /api/lookups/pass-fail`
- `GET /api/v1/lookups` — list all lookup sets
- `GET /api/v1/lookups/:code` — single lookup set with values
### Configuration
- `GET /api/config/image-rules`
- `GET /api/config/export`
- `GET /api/config/app-config`
- `GET /api/v1/config/image-rules` — active image policy
- `PUT /api/v1/config/image-rules` — update image policy (audit-logged)
- `GET /api/v1/config/export` — export profile
- `GET /api/v1/config/app-config` — generic app configuration
### Reports
- `GET /api/v1/reports` — list submitted reports (supports `?status=`, `?templateCode=`, `?limit=`, `?offset=`)
- `GET /api/v1/reports/:reportId` — single submitted report
- `POST /api/v1/reports` — submit or update a report (UPSERT by UUID)
## Example response
`GET /api/templates/incoming-inspection`
`GET /api/v1/templates/incoming-inspection`
```json
{
@@ -135,12 +239,3 @@ The test verifies:
}
}
```
## 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.
+196
View File
@@ -0,0 +1,196 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#f3efe6" />
<title>Check List PoC — Admin</title>
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<!--
Administrator workspace: server-backed configuration editing for image
policies and other centrally managed settings.
-->
<div class="app-shell">
<aside class="sidebar panel">
<div class="brand-block">
<p class="eyebrow">Hybrid Inspection Reporting</p>
<h1>Check List</h1>
<p class="lede">
Offline-first proof of concept for template-driven quality reports.
</p>
</div>
<div class="sidebar-section">
<div class="status-row">
<span id="connectionBadge" class="badge badge-neutral">Checking connection</span>
<span id="saveBadge" class="badge badge-neutral">No changes</span>
</div>
<button id="syncTemplatesButton" class="button button-secondary" type="button">
Sync templates
</button>
</div>
<div class="sidebar-section">
<label class="field-label" for="templateSelect">Template</label>
<select id="templateSelect" class="select-input"></select>
</div>
<div class="sidebar-section">
<div class="section-heading-row sidebar-links-heading">
<h2>Access</h2>
<span class="muted-count">Direct links</span>
</div>
<a id="userAreaLink" class="button button-secondary sidebar-link" href="/user">User area</a>
<a id="adminAreaLink" class="button button-secondary sidebar-link is-active" href="/admin">Admin area</a>
<a class="button button-secondary sidebar-link" href="/">Back to portal</a>
</div>
</aside>
<main class="workspace">
<section id="adminWorkspace" class="workspace-view workspace-view-active">
<section class="hero panel">
<div>
<p class="eyebrow">Administrator workspace</p>
<h2>Configuration control</h2>
<p class="hero-copy">
Update centrally managed image requirements used by the inspection frontend.
</p>
</div>
<div class="hero-actions">
<span id="adminSyncState" class="badge badge-neutral">Server-backed settings</span>
</div>
</section>
<section class="editor-grid">
<section class="panel editor-panel">
<div class="section-heading-row">
<h2>Image policy editor</h2>
<span class="panel-note">Updates the active server rule</span>
</div>
<form id="adminImageRulesForm" class="report-form admin-form">
<section class="template-section">
<div class="field-grid">
<div class="field field-full">
<div class="field-header">
<label class="field-label" for="adminPolicyName">Policy name</label>
</div>
<input id="adminPolicyName" name="name" class="text-input" type="text" />
</div>
<div class="field field-full">
<div class="field-header">
<label class="field-label" for="adminAllowedMimeTypes">Allowed MIME types</label>
</div>
<input
id="adminAllowedMimeTypes"
name="allowedMimeTypes"
class="text-input"
type="text"
placeholder="image/jpeg, image/png, image/webp"
/>
<p class="field-help">Comma-separated values used by the attachment field and browser validation.</p>
</div>
<div class="field">
<div class="field-header">
<label class="field-label" for="adminMaxFileSizeMb">Max file size (MB)</label>
</div>
<input id="adminMaxFileSizeMb" name="maxFileSizeMb" class="text-input" type="number" min="1" step="0.1" />
</div>
<div class="field">
<div class="field-header">
<label class="field-label" for="adminMaxAttachmentsPerField">Max attachments per field</label>
</div>
<input id="adminMaxAttachmentsPerField" name="maxAttachmentsPerField" class="text-input" type="number" min="1" step="1" />
</div>
<div class="field">
<div class="field-header">
<label class="field-label" for="adminMaxWidthPx">Max width (px)</label>
</div>
<input id="adminMaxWidthPx" name="maxWidthPx" class="text-input" type="number" min="1" step="1" />
</div>
<div class="field">
<div class="field-header">
<label class="field-label" for="adminMaxHeightPx">Max height (px)</label>
</div>
<input id="adminMaxHeightPx" name="maxHeightPx" class="text-input" type="number" min="1" step="1" />
</div>
<div class="field">
<div class="field-header">
<label class="field-label" for="adminJpegQuality">JPEG quality</label>
</div>
<input id="adminJpegQuality" name="jpegQuality" class="text-input" type="number" min="1" max="100" step="1" />
</div>
<div class="field">
<div class="field-header">
<label class="field-label" for="adminOversizeBehavior">Oversize behavior</label>
</div>
<select id="adminOversizeBehavior" name="oversizeBehavior" class="select-input">
<option value="auto_optimize">Auto optimize</option>
<option value="warn_then_optimize">Warn then optimize</option>
<option value="block">Block oversized files</option>
</select>
</div>
</div>
</section>
<div class="admin-actions">
<button id="saveImageRulesButton" class="button button-primary" type="submit">
Save image policy
</button>
<button id="resetImageRulesButton" class="button button-secondary" type="button">
Reset form
</button>
</div>
</form>
</section>
<aside class="panel inspector-panel">
<div class="section-heading-row">
<h2>Admin summary</h2>
<span class="panel-note">Live configuration preview</span>
</div>
<dl class="meta-list">
<div>
<dt>Active policy code</dt>
<dd id="adminPolicyCode">-</dd>
</div>
<div>
<dt>Allowed types</dt>
<dd id="adminPolicyMimeTypes">-</dd>
</div>
<div>
<dt>Optimization</dt>
<dd id="adminPolicyOptimization">-</dd>
</div>
<div>
<dt>Limits</dt>
<dd id="adminPolicyLimits">-</dd>
</div>
</dl>
<div class="validation-block">
<h3>Admin notes</h3>
<ul class="validation-list" id="adminNotesList">
<li>Changes are stored on the server and reused by report attachments.</li>
<li>Operators will use the updated policy after the next sync.</li>
</ul>
</div>
</aside>
</section>
</section>
</main>
</div>
<script type="module" src="/app.js"></script>
</body>
</html>
+780
View File
@@ -0,0 +1,780 @@
/*
* Check List PoC — Main entry point (orchestrator).
*
* This file wires together the split ES modules, binds DOM events, and manages
* the application lifecycle (init, sync, autosave, etc.). Business logic for
* rendering, validation, database, image optimization, and exports lives in
* dedicated modules under /js/.
*
* Architecture improvements implemented here:
* - A1: monolithic app.js split into focused ES modules
* - A2: single-request batch template fetch via ?include=definitions
* - A3: all API calls go through /api/v1/ (versioned endpoints)
* - A4: per-resource resilient sync (each resource saved independently)
* - A5: multi-store IndexedDB transactions for atomic deletes
* - A6: stale template cleanup after sync
* - A7: validation extracted to shared module (js/validation.js)
* - P2: debounced form re-render after field changes
* - P6: dirty-flag autosave — skips write when no changes occurred
* - F1: report submission to server
* - F4: report search & status filter
*/
import { state, elements, getCurrentReport, getTemplateRecord } from './js/state.js';
import {
STORE_TEMPLATES, STORE_LOOKUPS, STORE_CONFIG,
STORE_REPORTS, STORE_ATTACHMENTS, STORE_SETTINGS,
DEFAULT_AUTOSAVE_SECONDS, RENDER_DEBOUNCE_MS
} from './js/constants.js';
import {
openDatabase, dbGetAll, dbGet, dbPut, dbDelete,
dbGetAllByIndex, dbTransaction, saveSetting, loadSetting
} from './js/db.js';
import { fetchJson, registerServiceWorker } from './js/api.js';
import { optimizeImage } from './js/images.js';
import { validateImageRulesPayload } from './js/validation.js';
import { exportReportCSV, exportReportAttachments } from './js/export.js';
import {
render, renderReportList, renderCurrentReport, renderMeta,
renderValidation, renderImagePolicy, renderAdminImageRules,
renderTemplateSummary, populateAdminImageRulesForm,
updateConnectionBadge, updateSaveBadge
} from './js/renderer.js';
import {
makeTemplateKey, deriveTemplateCatalog, generateReportNumber,
buildGeneratedFilename, formatTime, debounce
} from './js/utils.js';
import { t } from './js/i18n.js';
/* ── Initialization ─────────────────────────────────────────────────────── */
document.addEventListener('DOMContentLoaded', () => {
void init();
});
async function init() {
/*
* Initialization restores local state first so the app remains useful offline.
* Only after the cache is available do we try to refresh from the server. That
* ordering is intentional because a blank UI during a failed sync would defeat
* the main offline-first goal of the PoC.
*/
cacheElements();
bindEvents();
state.db = await openDatabase();
await hydrateFromLocalCache();
registerServiceWorker();
startAutosaveLoop();
updateConnectionBadge();
if (navigator.onLine) {
await syncTemplatesAndConfig();
}
render(fieldCallbacks);
}
/* ── DOM caching ────────────────────────────────────────────────────────── */
function cacheElements() {
elements.connectionBadge = document.querySelector('#connectionBadge');
elements.saveBadge = document.querySelector('#saveBadge');
elements.syncTemplatesButton = document.querySelector('#syncTemplatesButton');
elements.templateSelect = document.querySelector('#templateSelect');
elements.createReportButton = document.querySelector('#createReportButton');
elements.userAreaLink = document.querySelector('#userAreaLink');
elements.adminAreaLink = document.querySelector('#adminAreaLink');
elements.reportList = document.querySelector('#reportList');
elements.reportListItemTemplate = document.querySelector('#reportListItemTemplate');
elements.reportCount = document.querySelector('#reportCount');
/* User workspace elements — null on the admin page. */
elements.heroTitle = document.querySelector('#heroTitle');
elements.heroSubtitle = document.querySelector('#heroSubtitle');
elements.reportStatusSelect = document.querySelector('#reportStatusSelect');
elements.deleteReportButton = document.querySelector('#deleteReportButton');
elements.submitReportButton = document.querySelector('#submitReportButton');
elements.exportReportButton = document.querySelector('#exportReportButton');
elements.summaryTemplate = document.querySelector('#summaryTemplate');
elements.summaryVersion = document.querySelector('#summaryVersion');
elements.validationHeadline = document.querySelector('#validationHeadline');
elements.validationDetail = document.querySelector('#validationDetail');
elements.syncHeadline = document.querySelector('#syncHeadline');
elements.syncDetail = document.querySelector('#syncDetail');
elements.reportForm = document.querySelector('#reportForm');
elements.editorHint = document.querySelector('#editorHint');
elements.reportMeta = document.querySelector('#reportMeta');
elements.validationList = document.querySelector('#validationList');
elements.imagePolicyText = document.querySelector('#imagePolicyText');
/* Admin workspace elements — null on the user page. */
elements.adminSyncState = document.querySelector('#adminSyncState');
elements.adminImageRulesForm = document.querySelector('#adminImageRulesForm');
elements.saveImageRulesButton = document.querySelector('#saveImageRulesButton');
elements.resetImageRulesButton = document.querySelector('#resetImageRulesButton');
elements.adminPolicyName = document.querySelector('#adminPolicyName');
elements.adminAllowedMimeTypes = document.querySelector('#adminAllowedMimeTypes');
elements.adminMaxFileSizeMb = document.querySelector('#adminMaxFileSizeMb');
elements.adminMaxAttachmentsPerField = document.querySelector('#adminMaxAttachmentsPerField');
elements.adminMaxWidthPx = document.querySelector('#adminMaxWidthPx');
elements.adminMaxHeightPx = document.querySelector('#adminMaxHeightPx');
elements.adminJpegQuality = document.querySelector('#adminJpegQuality');
elements.adminOversizeBehavior = document.querySelector('#adminOversizeBehavior');
elements.adminPolicyCode = document.querySelector('#adminPolicyCode');
elements.adminPolicyMimeTypes = document.querySelector('#adminPolicyMimeTypes');
elements.adminPolicyOptimization = document.querySelector('#adminPolicyOptimization');
elements.adminPolicyLimits = document.querySelector('#adminPolicyLimits');
/* F4 — search and filter controls (user page only). */
elements.reportSearchInput = document.querySelector('#reportSearchInput');
elements.reportFilterSelect = document.querySelector('#reportFilterSelect');
}
/* ── Event binding ──────────────────────────────────────────────────────── */
function bindEvents() {
elements.syncTemplatesButton.addEventListener('click', () => {
void syncTemplatesAndConfig();
});
elements.templateSelect.addEventListener('change', (event) => {
state.selectedTemplateCode = event.target.value || null;
void saveSetting('selectedTemplateCode', state.selectedTemplateCode);
if (elements.reportForm) {
renderTemplateSummary();
}
});
if (elements.createReportButton) {
elements.createReportButton.addEventListener('click', () => {
void createReport();
});
}
/* User workspace events — only bound when the report editor exists. */
if (elements.reportStatusSelect) {
elements.reportStatusSelect.addEventListener('change', (event) => {
const report = getCurrentReport();
if (!report) {
return;
}
report.status = event.target.value;
markDirty(t('statusChanged'));
renderReportList();
renderValidation();
});
}
if (elements.deleteReportButton) {
elements.deleteReportButton.addEventListener('click', () => {
void deleteCurrentReport();
});
}
/* F1 — submit report to server */
if (elements.submitReportButton) {
elements.submitReportButton.addEventListener('click', () => {
void submitCurrentReport();
});
}
/* F2 — CSV export */
if (elements.exportReportButton) {
elements.exportReportButton.addEventListener('click', () => {
void handleExport();
});
}
/* F4 — report search */
if (elements.reportSearchInput) {
elements.reportSearchInput.addEventListener('input', debounce((event) => {
state.reportSearchQuery = event.target.value.trim();
renderReportList();
}, 250));
}
/* F4 — report status filter */
if (elements.reportFilterSelect) {
elements.reportFilterSelect.addEventListener('change', (event) => {
state.reportFilterStatus = event.target.value;
renderReportList();
});
}
/* Admin workspace events — only bound when the admin form exists. */
if (elements.adminImageRulesForm) {
elements.adminImageRulesForm.addEventListener('submit', (event) => {
event.preventDefault();
void saveImageRules();
});
}
if (elements.resetImageRulesButton) {
elements.resetImageRulesButton.addEventListener('click', () => {
populateAdminImageRulesForm(state.imageRules);
});
}
/* Use event delegation on the report list to avoid per-item listeners. */
if (elements.reportList) {
elements.reportList.addEventListener('click', (event) => {
const button = event.target.closest('.report-list-item');
if (button?.dataset.reportId) {
void openReport(button.dataset.reportId);
}
});
}
window.addEventListener('online', () => {
updateConnectionBadge();
void syncTemplatesAndConfig();
});
window.addEventListener('offline', () => {
updateConnectionBadge();
});
}
/* ── Local cache hydration ──────────────────────────────────────────────── */
async function hydrateFromLocalCache() {
const [templateRows, lookupRows, configRows, reports, selectedTemplateCode, lastReportId, lastSyncAt] =
await Promise.all([
dbGetAll(STORE_TEMPLATES),
dbGetAll(STORE_LOOKUPS),
dbGetAll(STORE_CONFIG),
dbGetAll(STORE_REPORTS),
loadSetting('selectedTemplateCode'),
loadSetting('currentReportId'),
loadSetting('lastSyncAt')
]);
state.templateDefinitions = new Map(
templateRows.map((item) => [makeTemplateKey(item.code, item.version), item])
);
state.templatesCatalog = deriveTemplateCatalog(templateRows);
state.lookups = new Map(lookupRows.map((item) => [item.code, item]));
state.appConfig = new Map(configRows.map((item) => [item.key, item.value]));
state.imageRules = state.appConfig.get('imageRules') || null;
state.reports = reports.sort((left, right) => {
return new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime();
});
state.selectedTemplateCode = selectedTemplateCode || state.templatesCatalog[0]?.code || null;
state.lastSyncAt = lastSyncAt || null;
if (lastReportId && state.reports.some((report) => report.id === lastReportId)) {
await openReport(lastReportId);
}
}
/* ── Sync (A2 batch, A4 resilient, A6 stale cleanup) ───────────────────── */
async function syncTemplatesAndConfig() {
if (!navigator.onLine) {
updateSaveBadge(t('offlineMode'), 'warning');
return;
}
updateSaveBadge(t('syncingTemplates'), 'neutral');
/*
* A4 — Each resource type is fetched and persisted independently. If one
* resource fails (e.g. lookups), the others still save successfully so the
* local cache stays as fresh as possible.
*/
const errors = [];
/* A2 — Batch template fetch: single request returns all active definitions. */
try {
const templatesResponse = await fetchJson('/templates?include=definitions');
const templateRecords = templatesResponse.items.map((item) => ({
cacheKey: makeTemplateKey(item.code, item.version),
code: item.code,
name: item.name,
description: item.description,
version: item.version,
publishedAt: item.publishedAt,
definition: item.definition,
isActive: true
}));
await Promise.all(templateRecords.map((item) => dbPut(STORE_TEMPLATES, item)));
/* A6 — Remove stale templates no longer in the server's active set. */
const activeKeys = new Set(templateRecords.map((r) => r.cacheKey));
const cachedTemplates = await dbGetAll(STORE_TEMPLATES);
for (const cached of cachedTemplates) {
if (cached.isActive && !activeKeys.has(cached.cacheKey)) {
await dbDelete(STORE_TEMPLATES, cached.cacheKey);
}
}
const freshTemplateRows = await dbGetAll(STORE_TEMPLATES);
state.templateDefinitions = new Map(
freshTemplateRows.map((item) => [makeTemplateKey(item.code, item.version), item])
);
state.templatesCatalog = templatesResponse.items.map((item) => ({
code: item.code,
name: item.name,
description: item.description,
activeVersion: item.version,
publishedAt: item.publishedAt
}));
} catch (error) {
console.error('Template sync failed', error);
errors.push('templates');
}
try {
const lookupsResponse = await fetchJson('/lookups');
await Promise.all(lookupsResponse.items.map((item) => dbPut(STORE_LOOKUPS, item)));
state.lookups = new Map(lookupsResponse.items.map((item) => [item.code, item]));
} catch (error) {
console.error('Lookup sync failed', error);
errors.push('lookups');
}
try {
const [imageRules, exportProfile, appConfigResponse] = await Promise.all([
fetchJson('/config/image-rules'),
fetchJson('/config/export'),
fetchJson('/config/app-config')
]);
await Promise.all([
dbPut(STORE_CONFIG, { key: 'imageRules', value: imageRules }),
dbPut(STORE_CONFIG, { key: 'exportProfile', value: exportProfile }),
...appConfigResponse.items.map((item) => dbPut(STORE_CONFIG, { key: item.key, value: item.value }))
]);
state.imageRules = imageRules;
state.appConfig = new Map([
...appConfigResponse.items.map((item) => [item.key, item.value]),
['imageRules', imageRules],
['exportProfile', exportProfile]
]);
} catch (error) {
console.error('Config sync failed', error);
errors.push('config');
}
state.lastSyncAt = new Date().toISOString();
await saveSetting('lastSyncAt', state.lastSyncAt);
if (!state.selectedTemplateCode && state.templatesCatalog.length) {
state.selectedTemplateCode = state.templatesCatalog[0].code;
await saveSetting('selectedTemplateCode', state.selectedTemplateCode);
}
startAutosaveLoop();
if (errors.length) {
updateSaveBadge(`Partial sync (${errors.join(', ')} failed)`, 'warning');
} else {
updateSaveBadge(t('templatesSynced'), 'success');
}
render(fieldCallbacks);
}
/* ── Report CRUD ────────────────────────────────────────────────────────── */
async function createReport() {
const templateCode = state.selectedTemplateCode || elements.templateSelect.value;
if (!templateCode) {
updateSaveBadge(t('selectTemplate'), 'warning');
return;
}
const catalogEntry = state.templatesCatalog.find((item) => item.code === templateCode);
const versionNumber = catalogEntry?.activeVersion;
const template = getTemplateRecord(templateCode, versionNumber);
if (!template) {
updateSaveBadge(t('templateNotAvailable'), 'error');
return;
}
const report = buildNewReport(template);
state.reports.unshift(report);
state.currentReportId = report.id;
state.currentAttachments = [];
await Promise.all([
dbPut(STORE_REPORTS, report),
saveSetting('currentReportId', report.id)
]);
updateSaveBadge(t('newReportCreated'), 'success');
render(fieldCallbacks);
}
function buildNewReport(template) {
const now = new Date().toISOString();
const reportNumber = generateReportNumber();
const answers = {};
for (const section of template.definition.sections || []) {
for (const field of section.fields || []) {
if (field.defaultValue !== undefined) {
answers[field.id] = field.defaultValue;
} else if (field.id === 'reportNumber') {
answers[field.id] = reportNumber;
} else if (field.type === 'date') {
answers[field.id] = now.slice(0, 10);
} else if (field.type === 'checkbox') {
answers[field.id] = false;
} else {
answers[field.id] = '';
}
}
}
return {
id: crypto.randomUUID(),
reportNumber,
title: `${template.name} ${new Date().toLocaleDateString()}`,
templateCode: template.code,
templateVersion: template.version,
status: 'draft',
answers,
createdAt: now,
updatedAt: now
};
}
async function openReport(reportId) {
const report = state.reports.find((item) => item.id === reportId);
if (!report) {
return;
}
await flushDirtyReport();
state.currentReportId = reportId;
state.currentAttachments = await dbGetAllByIndex(STORE_ATTACHMENTS, 'byReportId', reportId);
state.selectedTemplateCode = report.templateCode;
await Promise.all([
saveSetting('currentReportId', reportId),
saveSetting('selectedTemplateCode', report.templateCode)
]);
render(fieldCallbacks);
}
async function deleteCurrentReport() {
const report = getCurrentReport();
if (!report) {
return;
}
const confirmed = window.confirm(t('deleteReportConfirm', report.reportNumber));
if (!confirmed) {
return;
}
/* A5 — Atomic multi-store delete: report + all its attachments in one tx. */
const attachments = await dbGetAllByIndex(STORE_ATTACHMENTS, 'byReportId', report.id);
await dbTransaction([STORE_REPORTS, STORE_ATTACHMENTS], 'readwrite', (getStore) => {
getStore(STORE_REPORTS).delete(report.id);
for (const attachment of attachments) {
getStore(STORE_ATTACHMENTS).delete(attachment.id);
}
});
state.reports = state.reports.filter((item) => item.id !== report.id);
state.currentReportId = state.reports[0]?.id || null;
state.currentAttachments = state.currentReportId
? await dbGetAllByIndex(STORE_ATTACHMENTS, 'byReportId', state.currentReportId)
: [];
await saveSetting('currentReportId', state.currentReportId);
updateSaveBadge(t('reportDeleted'), 'success');
render(fieldCallbacks);
}
/* ── Report submission to server (F1) ───────────────────────────────────── */
async function submitCurrentReport() {
const report = getCurrentReport();
if (!report) {
return;
}
if (!navigator.onLine) {
updateSaveBadge(t('goOnlineToSubmit'), 'warning');
return;
}
await flushDirtyReport();
updateSaveBadge(t('submitting'), 'neutral');
try {
await fetchJson('/reports', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: report.id,
reportNumber: report.reportNumber,
templateCode: report.templateCode,
templateVersion: report.templateVersion,
status: report.status,
answers: report.answers
})
});
report.status = 'exported';
report.updatedAt = new Date().toISOString();
await dbPut(STORE_REPORTS, report);
updateSaveBadge(t('submitted'), 'success');
renderReportList();
renderCurrentReport(fieldCallbacks);
} catch (error) {
console.error(error);
updateSaveBadge(t('submitFailed'), 'error');
}
}
/* ── Export (F2) ────────────────────────────────────────────────────────── */
async function handleExport() {
const report = getCurrentReport();
if (!report) {
updateSaveBadge(t('noReportToExport'), 'warning');
return;
}
updateSaveBadge(t('exportStarted'), 'neutral');
try {
await exportReportCSV();
await exportReportAttachments();
updateSaveBadge(t('exportComplete'), 'success');
} catch (error) {
console.error(error);
updateSaveBadge(t('exportFailed'), 'error');
}
}
/* ── Attachment operations ──────────────────────────────────────────────── */
async function attachFiles(field, report, files) {
if (!files.length) {
return;
}
const currentFieldAttachments = state.currentAttachments.filter((item) => item.fieldId === field.id);
const maxAttachments = field.maxAttachments || state.imageRules?.maxAttachmentsPerField || 5;
if (currentFieldAttachments.length + files.length > maxAttachments) {
updateSaveBadge(t('maxAttachments', maxAttachments), 'warning');
return;
}
let addedCount = 0;
for (let index = 0; index < files.length; index += 1) {
const file = files[index];
try {
const optimized = await optimizeImage(file, state.imageRules);
const sequence = currentFieldAttachments.length + addedCount + 1;
const generatedFilename = buildGeneratedFilename(report, field, sequence, optimized.extension);
const attachment = {
id: crypto.randomUUID(),
reportId: report.id,
fieldId: field.id,
originalFilename: file.name,
generatedFilename,
mimeType: optimized.blob.type,
sizeBytes: optimized.blob.size,
width: optimized.width,
height: optimized.height,
blob: optimized.blob,
createdAt: new Date().toISOString()
};
state.currentAttachments.push(attachment);
await dbPut(STORE_ATTACHMENTS, attachment);
addedCount += 1;
} catch (error) {
console.error(error);
updateSaveBadge(t('imageSkipped', file.name), 'error');
}
}
if (addedCount === 0) {
return;
}
report.updatedAt = new Date().toISOString();
markDirty(t('imagesUpdated'));
renderCurrentReport(fieldCallbacks);
}
async function removeAttachment(attachmentId) {
const report = getCurrentReport();
if (!report) {
return;
}
await dbDelete(STORE_ATTACHMENTS, attachmentId);
state.currentAttachments = state.currentAttachments.filter((item) => item.id !== attachmentId);
report.updatedAt = new Date().toISOString();
markDirty(t('attachmentRemoved'));
renderCurrentReport(fieldCallbacks);
}
/* ── Field change handler (P2 debounced) ────────────────────────────────── */
const debouncedRenderAfterFieldChange = debounce(() => {
renderMeta(getCurrentReport());
renderValidation();
renderReportList();
}, RENDER_DEBOUNCE_MS);
function updateFieldValue(field, nextValue) {
const report = getCurrentReport();
if (!report) {
return;
}
report.answers[field.id] = field.type === 'number' && nextValue !== '' ? Number(nextValue) : nextValue;
report.updatedAt = new Date().toISOString();
if (report.status === 'draft') {
report.status = 'in_progress';
if (elements.reportStatusSelect) {
elements.reportStatusSelect.value = report.status;
}
}
markDirty(t('draftUpdated'));
/* P2 — Debounce DOM updates so rapid typing does not trigger full re-renders. */
debouncedRenderAfterFieldChange();
}
/*
* Callback bundle passed into the renderer so form nodes can trigger state
* mutations without circular imports.
*/
const fieldCallbacks = {
onFieldChange: updateFieldValue,
onAttachFiles: attachFiles,
onRemoveAttachment: removeAttachment
};
/* ── Dirty-state tracking & autosave (P6) ───────────────────────────────── */
function markDirty(label) {
state.dirty = true;
state.saveState = 'dirty';
updateSaveBadge(label, 'warning');
clearTimeout(state.saveTimer);
state.saveTimer = window.setTimeout(() => {
void flushDirtyReport();
}, 700);
}
async function flushDirtyReport() {
/* P6 — Skip write when no changes have been made since the last save. */
if (!state.dirty && state.saveState !== 'dirty') {
return;
}
const report = getCurrentReport();
if (!report) {
return;
}
report.updatedAt = new Date().toISOString();
await dbPut(STORE_REPORTS, report);
state.reports = state.reports
.map((item) => (item.id === report.id ? structuredClone(report) : item))
.sort((left, right) => new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime());
state.dirty = false;
state.saveState = 'idle';
updateSaveBadge(t('saved', formatTime(report.updatedAt)), 'success');
renderReportList();
renderMeta(report);
}
function startAutosaveLoop() {
clearInterval(state.autosaveIntervalId);
const autosaveConfig = state.appConfig.get('autosave') || { intervalSeconds: DEFAULT_AUTOSAVE_SECONDS };
const intervalSeconds = Number(autosaveConfig.intervalSeconds || DEFAULT_AUTOSAVE_SECONDS);
state.autosaveIntervalId = window.setInterval(() => {
void flushDirtyReport();
}, intervalSeconds * 1000);
}
/* ── Admin: image rules ─────────────────────────────────────────────────── */
async function saveImageRules() {
if (!navigator.onLine) {
updateSaveBadge(t('goOnlineToSave'), 'warning');
return;
}
const payload = collectImageRulesPayload();
const validationMessage = validateImageRulesPayload(payload);
if (validationMessage) {
updateSaveBadge(validationMessage, 'error');
return;
}
elements.saveImageRulesButton.disabled = true;
updateSaveBadge(t('savingImagePolicy'), 'neutral');
try {
const nextImageRules = await fetchJson('/config/image-rules', {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(payload)
});
state.imageRules = nextImageRules;
state.appConfig.set('imageRules', nextImageRules);
await dbPut(STORE_CONFIG, { key: 'imageRules', value: nextImageRules });
renderImagePolicy();
renderAdminImageRules();
updateSaveBadge(t('imagePolicySaved'), 'success');
} catch (error) {
console.error(error);
updateSaveBadge(error.message || t('submitFailed'), 'error');
} finally {
elements.saveImageRulesButton.disabled = false;
}
}
function collectImageRulesPayload() {
return {
name: elements.adminPolicyName.value.trim(),
allowedMimeTypes: elements.adminAllowedMimeTypes.value
.split(',')
.map((value) => value.trim())
.filter(Boolean),
maxFileSizeBytes: Math.round(Number(elements.adminMaxFileSizeMb.value) * 1024 * 1024),
maxWidthPx: Number(elements.adminMaxWidthPx.value),
maxHeightPx: Number(elements.adminMaxHeightPx.value),
jpegQuality: Number(elements.adminJpegQuality.value),
oversizeBehavior: elements.adminOversizeBehavior.value,
maxAttachmentsPerField: Number(elements.adminMaxAttachmentsPerField.value)
};
}
+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="96" fill="#9d3d2e"/>
<rect x="96" y="80" width="320" height="400" rx="32" fill="#fff8f0"/>
<line x1="160" y1="180" x2="360" y2="180" stroke="#e8dcc7" stroke-width="12" stroke-linecap="round"/>
<line x1="160" y1="250" x2="360" y2="250" stroke="#e8dcc7" stroke-width="12" stroke-linecap="round"/>
<line x1="160" y1="320" x2="320" y2="320" stroke="#e8dcc7" stroke-width="12" stroke-linecap="round"/>
<polyline points="128,170 148,190 188,150" fill="none" stroke="#25624c" stroke-width="14" stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="128,240 148,260 188,220" fill="none" stroke="#25624c" stroke-width="14" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="128" y="305" width="40" height="40" rx="6" fill="none" stroke="#685f53" stroke-width="8"/>
</svg>

After

Width:  |  Height:  |  Size: 896 B

+347
View File
@@ -0,0 +1,347 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#f3efe6" />
<title>Check List PoC</title>
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<!--
This document is the shared app shell for both operator and administrator
routes. JavaScript decides which workspace to reveal based on the current
URL so the project can keep one frontend bundle while still presenting two
distinct entry points.
-->
<div class="app-shell">
<aside class="sidebar panel">
<!--
The sidebar keeps app-level actions visible across both workspaces:
sync status, template selection, navigation links, and the local draft
list. That supports quick report switching on small operational screens.
-->
<div class="brand-block">
<p class="eyebrow">Hybrid Inspection Reporting</p>
<h1>Check List</h1>
<p class="lede">
Offline-first proof of concept for template-driven quality reports.
</p>
</div>
<div class="sidebar-section">
<div class="status-row">
<span id="connectionBadge" class="badge badge-neutral">Checking connection</span>
<span id="saveBadge" class="badge badge-neutral">No changes</span>
</div>
<button id="syncTemplatesButton" class="button button-secondary" type="button">
Sync templates
</button>
</div>
<div class="sidebar-section">
<label class="field-label" for="templateSelect">Template</label>
<select id="templateSelect" class="select-input"></select>
<button id="createReportButton" class="button button-primary" type="button">
Create new report
</button>
</div>
<div class="sidebar-section">
<div class="section-heading-row sidebar-links-heading">
<h2>Access</h2>
<span class="muted-count">Direct links</span>
</div>
<a id="userAreaLink" class="button button-secondary sidebar-link" href="/user">User area</a>
<a id="adminAreaLink" class="button button-secondary sidebar-link" href="/admin">Admin area</a>
<a class="button button-secondary sidebar-link" href="/">Back to portal</a>
</div>
<div class="sidebar-section grow-section">
<div class="section-heading-row">
<h2>Local reports</h2>
<span id="reportCount" class="muted-count">0</span>
</div>
<!-- F4 — Search and status filter for the local report list -->
<div class="report-filter-row">
<input id="reportSearchInput" class="text-input text-input-small" type="search" placeholder="Search reports" />
<select id="reportFilterSelect" class="select-input select-input-small">
<option value="">All statuses</option>
<option value="draft">Draft</option>
<option value="in_progress">In Progress</option>
<option value="ready_for_export">Ready for Export</option>
<option value="exported">Exported</option>
<option value="archived">Archived</option>
</select>
</div>
<div id="reportList" class="report-list"></div>
</div>
</aside>
<main class="workspace">
<!-- Operator workspace: draft editing, validation, and local attachments. -->
<section id="reportsWorkspace" class="workspace-view workspace-view-active">
<section class="hero panel">
<div>
<p class="eyebrow">Proof of concept frontend</p>
<h2 id="heroTitle">No report selected</h2>
<p id="heroSubtitle" class="hero-copy">
Start by syncing templates and creating a local draft.
</p>
</div>
<div class="hero-actions">
<label class="status-picker">
<span>Status</span>
<select id="reportStatusSelect" class="select-input">
<option value="draft">Draft</option>
<option value="in_progress">In Progress</option>
<option value="ready_for_export">Ready for Export</option>
<option value="exported">Exported</option>
<option value="archived">Archived</option>
</select>
</label>
<button id="submitReportButton" class="button button-secondary" type="button">
Submit
</button>
<button id="exportReportButton" class="button button-secondary" type="button">
Export CSV
</button>
<button id="deleteReportButton" class="button button-ghost" type="button">
Delete report
</button>
</div>
</section>
<section class="summary-grid">
<article class="summary-card panel accent-card">
<p class="summary-label">Template</p>
<strong id="summaryTemplate">Not loaded</strong>
<span id="summaryVersion" class="summary-note">Version -</span>
</article>
<article class="summary-card panel">
<p class="summary-label">Validation</p>
<strong id="validationHeadline">No report selected</strong>
<span id="validationDetail" class="summary-note">Draft validation will appear here.</span>
</article>
<article class="summary-card panel">
<p class="summary-label">Offline cache</p>
<strong id="syncHeadline">No sync yet</strong>
<span id="syncDetail" class="summary-note">Templates are cached locally after the first successful sync.</span>
</article>
</section>
<section class="editor-grid">
<section class="panel editor-panel">
<div class="section-heading-row">
<h2>Report editor</h2>
<span id="editorHint" class="panel-note">Dynamic form rendering from template JSON</span>
</div>
<form id="reportForm" class="report-form">
<div class="empty-state">
<h3>No report open</h3>
<p>Choose a template and create a report to start editing locally.</p>
</div>
</form>
</section>
<aside class="panel inspector-panel">
<div class="section-heading-row">
<h2>Inspector view</h2>
<span class="panel-note">Local draft summary</span>
</div>
<dl id="reportMeta" class="meta-list">
<div>
<dt>Report ID</dt>
<dd>-</dd>
</div>
<div>
<dt>Template</dt>
<dd>-</dd>
</div>
<div>
<dt>Created</dt>
<dd>-</dd>
</div>
<div>
<dt>Updated</dt>
<dd>-</dd>
</div>
</dl>
<div class="validation-block">
<h3>Validation issues</h3>
<ul id="validationList" class="validation-list">
<li>No report selected.</li>
</ul>
</div>
<div class="attachment-policy">
<h3>Image policy</h3>
<p id="imagePolicyText" class="policy-copy">
Load server configuration to see image limits and optimization rules.
</p>
</div>
</aside>
</section>
</section>
<!-- Administrator workspace: server-backed configuration editing. -->
<section id="adminWorkspace" class="workspace-view" hidden>
<section class="hero panel">
<div>
<p class="eyebrow">Administrator workspace</p>
<h2>Configuration control</h2>
<p class="hero-copy">
Update centrally managed image requirements used by the inspection frontend.
</p>
</div>
<div class="hero-actions">
<span id="adminSyncState" class="badge badge-neutral">Server-backed settings</span>
</div>
</section>
<section class="editor-grid">
<section class="panel editor-panel">
<div class="section-heading-row">
<h2>Image policy editor</h2>
<span class="panel-note">Updates the active server rule</span>
</div>
<form id="adminImageRulesForm" class="report-form admin-form">
<section class="template-section">
<div class="field-grid">
<div class="field field-full">
<div class="field-header">
<label class="field-label" for="adminPolicyName">Policy name</label>
</div>
<input id="adminPolicyName" name="name" class="text-input" type="text" />
</div>
<div class="field field-full">
<div class="field-header">
<label class="field-label" for="adminAllowedMimeTypes">Allowed MIME types</label>
</div>
<input
id="adminAllowedMimeTypes"
name="allowedMimeTypes"
class="text-input"
type="text"
placeholder="image/jpeg, image/png, image/webp"
/>
<p class="field-help">Comma-separated values used by the attachment field and browser validation.</p>
</div>
<div class="field">
<div class="field-header">
<label class="field-label" for="adminMaxFileSizeMb">Max file size (MB)</label>
</div>
<input id="adminMaxFileSizeMb" name="maxFileSizeMb" class="text-input" type="number" min="1" step="0.1" />
</div>
<div class="field">
<div class="field-header">
<label class="field-label" for="adminMaxAttachmentsPerField">Max attachments per field</label>
</div>
<input id="adminMaxAttachmentsPerField" name="maxAttachmentsPerField" class="text-input" type="number" min="1" step="1" />
</div>
<div class="field">
<div class="field-header">
<label class="field-label" for="adminMaxWidthPx">Max width (px)</label>
</div>
<input id="adminMaxWidthPx" name="maxWidthPx" class="text-input" type="number" min="1" step="1" />
</div>
<div class="field">
<div class="field-header">
<label class="field-label" for="adminMaxHeightPx">Max height (px)</label>
</div>
<input id="adminMaxHeightPx" name="maxHeightPx" class="text-input" type="number" min="1" step="1" />
</div>
<div class="field">
<div class="field-header">
<label class="field-label" for="adminJpegQuality">JPEG quality</label>
</div>
<input id="adminJpegQuality" name="jpegQuality" class="text-input" type="number" min="1" max="100" step="1" />
</div>
<div class="field">
<div class="field-header">
<label class="field-label" for="adminOversizeBehavior">Oversize behavior</label>
</div>
<select id="adminOversizeBehavior" name="oversizeBehavior" class="select-input">
<option value="auto_optimize">Auto optimize</option>
<option value="warn_then_optimize">Warn then optimize</option>
<option value="block">Block oversized files</option>
</select>
</div>
</div>
</section>
<div class="admin-actions">
<button id="saveImageRulesButton" class="button button-primary" type="submit">
Save image policy
</button>
<button id="resetImageRulesButton" class="button button-secondary" type="button">
Reset form
</button>
</div>
</form>
</section>
<aside class="panel inspector-panel">
<div class="section-heading-row">
<h2>Admin summary</h2>
<span class="panel-note">Live configuration preview</span>
</div>
<dl class="meta-list">
<div>
<dt>Active policy code</dt>
<dd id="adminPolicyCode">-</dd>
</div>
<div>
<dt>Allowed types</dt>
<dd id="adminPolicyMimeTypes">-</dd>
</div>
<div>
<dt>Optimization</dt>
<dd id="adminPolicyOptimization">-</dd>
</div>
<div>
<dt>Limits</dt>
<dd id="adminPolicyLimits">-</dd>
</div>
</dl>
<div class="validation-block">
<h3>Admin notes</h3>
<ul class="validation-list" id="adminNotesList">
<li>Changes are stored on the server and reused by report attachments.</li>
<li>Operators will use the updated policy after the next sync.</li>
</ul>
</div>
</aside>
</section>
</section>
</main>
</div>
<!--
Report list items are rendered from this template at runtime so the sidebar
can update without rebuilding the entire page markup from strings.
-->
<template id="reportListItemTemplate">
<button class="report-list-item" type="button" data-report-id="">
<span class="report-list-item__header">
<strong class="report-list-item__title"></strong>
<span class="report-list-item__status badge"></span>
</span>
<span class="report-list-item__meta"></span>
</button>
</template>
<script type="module" src="/app.js"></script>
</body>
</html>
+50
View File
@@ -0,0 +1,50 @@
/*
* API communication module. Centralizes fetch calls and service-worker
* registration so network details stay out of rendering and state logic.
*/
import { API_BASE } from './constants.js';
/*
* Generic JSON fetcher. All frontend API calls pass through this function so
* error handling, header defaults, and base path are consistent everywhere.
*/
export async function fetchJson(path, options = {}) {
const url = path.startsWith('http') ? path : `${API_BASE}${path}`;
const requestOptions = {
...options,
headers: {
Accept: 'application/json',
...(options.headers || {})
}
};
const response = await fetch(url, requestOptions);
if (!response.ok) {
let message = `Request failed for ${url}: ${response.status}`;
try {
const errorPayload = await response.json();
if (errorPayload?.message) {
message = errorPayload.message;
}
} catch {
/* Ignore JSON parse errors for non-JSON responses. */
}
throw new Error(message);
}
return response.json();
}
export function registerServiceWorker() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch((error) => {
console.error('Service worker registration failed', error);
});
}
}
+30
View File
@@ -0,0 +1,30 @@
/*
* Application-wide constants shared by all frontend modules.
*
* Centralizing these values prevents magic strings and numbers from being
* scattered across the codebase and makes configuration changes easy to trace.
*/
/* IndexedDB database name and schema version (bump when stores change). */
export const DB_NAME = 'check-list-poc-db';
export const DB_VERSION = 2;
/* IndexedDB object store names. */
export const STORE_TEMPLATES = 'templates';
export const STORE_LOOKUPS = 'lookups';
export const STORE_CONFIG = 'config';
export const STORE_REPORTS = 'reports';
export const STORE_ATTACHMENTS = 'attachments';
export const STORE_SETTINGS = 'settings';
/* Autosave interval used when the server does not supply a value. */
export const DEFAULT_AUTOSAVE_SECONDS = 20;
/* Base path for all versioned API calls. */
export const API_BASE = '/api/v1';
/* Minimum delay before re-rendering the form after a field change. */
export const RENDER_DEBOUNCE_MS = 200;
/* Maximum entries kept in the Service Worker dynamic cache (LRU eviction). */
export const SW_DYNAMIC_CACHE_LIMIT = 50;
+133
View File
@@ -0,0 +1,133 @@
/*
* IndexedDB operations module. Provides typed helpers for reading and writing to
* the browser-side database. All functions depend on `state.db` being set during
* initialization.
*
* Changes from the original monolithic app.js:
* - A5: `dbTransaction()` wraps multi-store operations in a single IndexedDB
* transaction so related writes (e.g. delete report + delete attachments) are
* atomic and won't leave orphaned records on mid-operation failure.
*/
import { state } from './state.js';
import {
DB_NAME,
DB_VERSION,
STORE_TEMPLATES,
STORE_LOOKUPS,
STORE_CONFIG,
STORE_REPORTS,
STORE_ATTACHMENTS,
STORE_SETTINGS
} from './constants.js';
/* ── Database bootstrap ─────────────────────────────────────────────────── */
export function openDatabase() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = (event) => {
const db = request.result;
if (!db.objectStoreNames.contains(STORE_TEMPLATES)) {
db.createObjectStore(STORE_TEMPLATES, { keyPath: 'cacheKey' });
}
if (!db.objectStoreNames.contains(STORE_LOOKUPS)) {
db.createObjectStore(STORE_LOOKUPS, { keyPath: 'code' });
}
if (!db.objectStoreNames.contains(STORE_CONFIG)) {
db.createObjectStore(STORE_CONFIG, { keyPath: 'key' });
}
if (!db.objectStoreNames.contains(STORE_REPORTS)) {
db.createObjectStore(STORE_REPORTS, { keyPath: 'id' });
}
if (!db.objectStoreNames.contains(STORE_ATTACHMENTS)) {
const store = db.createObjectStore(STORE_ATTACHMENTS, { keyPath: 'id' });
store.createIndex('byReportId', 'reportId', { unique: false });
}
if (!db.objectStoreNames.contains(STORE_SETTINGS)) {
db.createObjectStore(STORE_SETTINGS, { keyPath: 'key' });
}
};
});
}
/* ── Single-store helpers ───────────────────────────────────────────────── */
export function dbGetAll(storeName) {
return executeStoreRequest(storeName, 'readonly', (store) => store.getAll());
}
export function dbGet(storeName, key) {
return executeStoreRequest(storeName, 'readonly', (store) => store.get(key));
}
export function dbPut(storeName, value) {
return executeStoreRequest(storeName, 'readwrite', (store) => store.put(value));
}
export function dbDelete(storeName, key) {
return executeStoreRequest(storeName, 'readwrite', (store) => store.delete(key));
}
export function dbGetAllByIndex(storeName, indexName, key) {
return executeStoreRequest(storeName, 'readonly', (store) => {
return store.index(indexName).getAll(IDBKeyRange.only(key));
});
}
function executeStoreRequest(storeName, mode, callback) {
return new Promise((resolve, reject) => {
const transaction = state.db.transaction(storeName, mode);
const store = transaction.objectStore(storeName);
const request = callback(store);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
/* ── Multi-store transaction (A5) ───────────────────────────────────────── */
/*
* Wraps multiple writes across different object stores in a single IndexedDB
* transaction. The callback receives a helper that returns a store by name.
* All writes either commit together or roll back as a unit.
*
* Example:
* await dbTransaction([STORE_REPORTS, STORE_ATTACHMENTS], 'readwrite', (getStore) => {
* getStore(STORE_REPORTS).delete(reportId);
* getStore(STORE_ATTACHMENTS).delete(attachmentId);
* });
*/
export function dbTransaction(storeNames, mode, callback) {
return new Promise((resolve, reject) => {
const tx = state.db.transaction(storeNames, mode);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error || new Error('Transaction aborted'));
const getStore = (name) => tx.objectStore(name);
callback(getStore);
});
}
/* ── Settings helpers ───────────────────────────────────────────────────── */
export async function saveSetting(key, value) {
await dbPut(STORE_SETTINGS, { key, value });
}
export async function loadSetting(key) {
const record = await dbGet(STORE_SETTINGS, key);
return record?.value;
}
+100
View File
@@ -0,0 +1,100 @@
/*
* CSV and attachment export module (F2). Generates a CSV file from the current
* report's answers and allows downloading individual attachments. XLSX and ZIP
* export can be added by integrating SheetJS and JSZip libraries.
*/
import { state, getCurrentReport, getTemplateRecord } from './state.js';
import { dbGetAllByIndex } from './db.js';
import { STORE_ATTACHMENTS } from './constants.js';
/*
* Exports the active report as a CSV file. Columns are derived from the template
* definition so field labels appear as headers and field values as the row.
*/
export async function exportReportCSV() {
const report = getCurrentReport();
if (!report) {
throw new Error('No report to export');
}
const template = getTemplateRecord(report.templateCode, report.templateVersion);
if (!template) {
throw new Error('Template definition needed for export');
}
const headers = [];
const values = [];
/* Meta columns. */
headers.push('Report Number', 'Template', 'Version', 'Status', 'Created', 'Updated');
values.push(
report.reportNumber,
report.templateCode,
String(report.templateVersion),
report.status,
report.createdAt,
report.updatedAt
);
/* Dynamic field columns derived from the template definition. */
for (const section of template.definition.sections || []) {
for (const field of section.fields || []) {
if (field.type === 'attachment') {
continue;
}
headers.push(field.label);
const raw = report.answers[field.id];
values.push(raw === undefined || raw === null ? '' : String(raw));
}
}
const csvContent = [
headers.map(csvEscape).join(','),
values.map(csvEscape).join(',')
].join('\r\n');
downloadBlob(
new Blob([csvContent], { type: 'text/csv;charset=utf-8' }),
`${report.reportNumber || 'report'}.csv`
);
}
/*
* Exports all attachments for the active report as individual file downloads.
* A future iteration could bundle these into a ZIP archive using JSZip.
*/
export async function exportReportAttachments() {
const report = getCurrentReport();
if (!report) {
throw new Error('No report to export');
}
const attachments = await dbGetAllByIndex(STORE_ATTACHMENTS, 'byReportId', report.id);
for (const attachment of attachments) {
downloadBlob(attachment.blob, attachment.generatedFilename);
}
}
/* ── Helpers ────────────────────────────────────────────────────────────── */
function csvEscape(value) {
const str = String(value).replace(/"/g, '""');
return /[",\r\n]/.test(str) ? `"${str}"` : str;
}
function downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
}
+239
View File
@@ -0,0 +1,239 @@
/*
* Dynamic form field creation. Each function returns a DOM node tree for a
* single template field. The approach keeps template-driven rendering in one
* module while the orchestrator (app.js) provides callbacks for state mutations.
*
* Callback contract:
* onFieldChange(field, nextValue) — called when the user edits a field
* onAttachFiles(field, report, files) — called when files are selected
* onRemoveAttachment(attachmentId) — called to remove an attachment
*/
import { evaluateRequiredWhen } from './validation.js';
import { formatFileSize } from './utils.js';
export function createFieldNode(field, report, { state, onFieldChange, onAttachFiles, onRemoveAttachment }) {
const wrapper = document.createElement('div');
wrapper.className = `field ${field.type === 'comment' || field.type === 'attachment' ? 'field-full' : ''}`;
const isRequired = Boolean(field.required || evaluateRequiredWhen(field.requiredWhen, report.answers));
const currentValue = report.answers[field.id];
wrapper.innerHTML = `
<div class="field-header">
<label class="field-label" for="field-${field.id}">${escapeHtml(field.label)}</label>
${isRequired ? '<span class="required-pill">Required</span>' : ''}
</div>
`;
let inputNode;
switch (field.type) {
case 'text':
case 'date':
case 'number':
inputNode = document.createElement('input');
inputNode.className = 'text-input';
inputNode.id = `field-${field.id}`;
inputNode.name = field.id;
inputNode.type = field.type;
inputNode.value = currentValue ?? '';
inputNode.readOnly = Boolean(field.readOnly);
if (field.validation?.min !== undefined) {
inputNode.min = String(field.validation.min);
}
inputNode.addEventListener('input', (event) => {
onFieldChange(field, event.target.value);
});
break;
case 'lookup': {
inputNode = document.createElement('select');
inputNode.className = 'select-input';
inputNode.id = `field-${field.id}`;
inputNode.name = field.id;
const emptyOption = document.createElement('option');
emptyOption.value = '';
emptyOption.textContent = 'Select an option';
inputNode.append(emptyOption);
const lookup = state.lookups.get(field.lookupCode);
for (const optionData of lookup?.values || []) {
const option = document.createElement('option');
option.value = optionData.value;
option.textContent = optionData.label;
option.selected = optionData.value === currentValue;
inputNode.append(option);
}
inputNode.addEventListener('change', (event) => {
onFieldChange(field, event.target.value);
});
break;
}
case 'checkbox':
inputNode = document.createElement('label');
inputNode.className = 'checkbox-row';
inputNode.innerHTML = `
<input id="field-${field.id}" name="${field.id}" type="checkbox" ${currentValue ? 'checked' : ''} />
<span>${escapeHtml(field.label)}</span>
`;
inputNode.querySelector('input').addEventListener('change', (event) => {
onFieldChange(field, event.target.checked);
});
break;
case 'comment':
inputNode = document.createElement('textarea');
inputNode.className = 'text-area';
inputNode.id = `field-${field.id}`;
inputNode.name = field.id;
inputNode.maxLength = field.maxLength || 5000;
inputNode.value = currentValue ?? '';
inputNode.addEventListener('input', (event) => {
onFieldChange(field, event.target.value);
});
break;
case 'attachment':
inputNode = createAttachmentFieldNode(field, report, { state, onAttachFiles, onRemoveAttachment });
break;
default:
inputNode = document.createElement('input');
inputNode.className = 'text-input';
inputNode.id = `field-${field.id}`;
inputNode.name = field.id;
inputNode.type = 'text';
inputNode.value = currentValue ?? '';
inputNode.addEventListener('input', (event) => {
onFieldChange(field, event.target.value);
});
}
wrapper.append(inputNode);
if (field.requiredWhen?.field) {
const help = document.createElement('p');
help.className = 'field-help';
help.textContent = `Required when ${field.requiredWhen.field} is ${String(field.requiredWhen.equals)}.`;
wrapper.append(help);
}
return wrapper;
}
/* ── Attachment field ───────────────────────────────────────────────────── */
function createAttachmentFieldNode(field, report, { state, onAttachFiles, onRemoveAttachment }) {
const container = document.createElement('div');
container.className = 'attachment-list';
const toolbar = document.createElement('div');
toolbar.className = 'attachment-toolbar';
const input = document.createElement('input');
input.className = 'file-input';
input.id = `field-${field.id}`;
input.type = 'file';
input.accept = state.imageRules?.allowedMimeTypes?.join(',') || 'image/*';
input.multiple = true;
input.addEventListener('change', async (event) => {
const files = Array.from(event.target.files || []);
await onAttachFiles(field, report, files);
event.target.value = '';
});
toolbar.append(input);
container.append(toolbar);
/*
* P3 — Lazy-load attachment previews. Thumbnails are created from object URLs
* on demand using IntersectionObserver. Only attachments scrolled into view
* allocate a Blob URL, keeping memory use proportional to the visible area.
*/
const attachments = state.currentAttachments.filter((item) => item.fieldId === field.id);
if (!attachments.length) {
const hint = document.createElement('p');
hint.className = 'field-help';
hint.textContent = 'No images attached yet.';
container.append(hint);
return container;
}
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) {
continue;
}
const img = entry.target;
const attachment = attachments.find((a) => a.id === img.dataset.attachmentId);
if (attachment?.blob) {
const objectUrl = URL.createObjectURL(attachment.blob);
img.src = objectUrl;
img.addEventListener('load', () => URL.revokeObjectURL(objectUrl), { once: true });
}
observer.unobserve(img);
}
}, { rootMargin: '200px' });
for (const attachment of attachments) {
const card = document.createElement('article');
card.className = 'attachment-card';
const preview = document.createElement('img');
preview.className = 'attachment-preview';
preview.alt = attachment.generatedFilename;
preview.dataset.attachmentId = attachment.id;
/* Actual src loaded lazily by the IntersectionObserver above. */
observer.observe(preview);
const copy = document.createElement('div');
copy.className = 'attachment-card__copy';
copy.innerHTML = `
<strong>${escapeHtml(attachment.generatedFilename)}</strong>
<span>${escapeHtml(attachment.originalFilename)}</span>
<span>${attachment.width}x${attachment.height}px &bull; ${formatFileSize(attachment.sizeBytes)}</span>
`;
const removeButton = document.createElement('button');
removeButton.type = 'button';
removeButton.className = 'button button-small button-ghost';
removeButton.textContent = 'Remove';
removeButton.addEventListener('click', () => {
void onRemoveAttachment(attachment.id);
});
card.append(preview, copy, removeButton);
container.append(card);
}
return container;
}
/* ── Helpers ────────────────────────────────────────────────────────────── */
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
+121
View File
@@ -0,0 +1,121 @@
/*
* Lightweight i18n module. All user-facing strings are collected here so the app
* can be translated by swapping or extending the locale object. The current
* implementation is English-only; a future iteration could load locale files
* dynamically based on a user preference.
*/
const en = {
/* General */
appTitle: 'Check List',
appTagline: 'Offline-first proof of concept for template-driven quality reports.',
checkingConnection: 'Checking connection',
online: 'Online',
offline: 'Offline',
noChanges: 'No changes',
/* Sync */
syncingTemplates: 'Syncing templates',
templatesSynced: 'Templates synced',
syncFailed: 'Sync failed, using cache',
offlineMode: 'Offline mode',
/* Reports */
noReportSelected: 'No report selected',
noReportSelectedHint: 'Start by syncing templates and creating a local draft.',
noLocalReports: 'No local reports yet.',
selectTemplate: 'Select a template first',
templateNotAvailable: 'Template not available locally',
newReportCreated: 'New report created',
reportDeleted: 'Report deleted',
deleteReportConfirm: 'Delete local report {0}?',
draftUpdated: 'Draft updated',
statusChanged: 'Status changed',
saved: 'Saved {0}',
/* Submission */
submitting: 'Submitting report…',
submitted: 'Report submitted to server',
submitFailed: 'Submission failed',
goOnlineToSubmit: 'Go online to submit a report',
/* Validation */
readyForExport: 'Ready for export validation',
noBlockingIssues: 'No blocking validation issues detected for the current draft.',
noValidationIssues: 'No validation issues.',
issueCount: '{0} issue(s) to resolve',
issueReadyWarning: 'The report is marked ready, but validation still has blocking items.',
issueDraftHint: 'Draft save is still allowed, but export should be blocked until these issues are fixed.',
templateUnavailable: 'Template unavailable',
validationNeedsTemplate: 'Validation cannot run until the template is cached again.',
required: 'value is required.',
numberInvalid: 'number is invalid.',
numberMin: 'must be at least {0}.',
imageRequired: 'at least one image is required.',
templateMissing: 'Template definition missing for this report version.',
/* Attachments */
noImagesAttached: 'No images attached yet.',
maxAttachments: 'Only {0} attachment(s) allowed',
imageSkipped: 'Image skipped: {0}',
imagesUpdated: 'Images updated',
attachmentRemoved: 'Attachment removed',
unsupportedFileType: 'Unsupported file type: {0}',
fileExceedsLimit: 'File exceeds limit: {0}',
optimizeFailed: 'Failed to optimize image: {0}',
optimizedStillExceeds: 'Optimized image still exceeds limit: {0}',
/* Admin */
savingImagePolicy: 'Saving image policy',
imagePolicySaved: 'Image policy saved',
goOnlineToSave: 'Go online to save admin settings',
policyNameRequired: 'Policy name is required',
addMimeType: 'Add at least one MIME type',
maxFileSizePositive: 'Max file size must be greater than 0',
maxWidthPositive: 'Max width must be a positive number',
maxHeightPositive: 'Max height must be a positive number',
jpegQualityRange: 'JPEG quality must be between 1 and 100',
maxAttachmentsPositive: 'Max attachments must be a positive number',
/* Template management */
publishingVersion: 'Publishing version…',
versionPublished: 'Template version published',
publishFailed: 'Failed to publish version',
/* Export */
exportStarted: 'Preparing export…',
exportComplete: 'Export ready — file downloaded',
exportFailed: 'Export failed',
noReportToExport: 'Open a report before exporting',
noTemplateForExport: 'Template definition needed for export',
/* Search */
searchPlaceholder: 'Search reports…',
/* Misc */
noTemplatesCached: 'No cached templates available',
liveConfig: 'Live server configuration',
offlineCachedConfig: 'Offline cached configuration',
noImageRulesLoaded: 'No image rules loaded',
imagePolicyHint: 'Load server configuration to see image limits and optimization rules.'
};
let currentLocale = en;
/*
* Retrieve a translated string by key. Optional positional parameters replace
* {0}, {1}, etc. placeholders.
*/
export function t(key, ...params) {
let text = currentLocale[key] ?? key;
for (let i = 0; i < params.length; i++) {
text = text.replace(`{${i}}`, String(params[i]));
}
return text;
}
export function setLocale(locale) {
currentLocale = { ...en, ...locale };
}
+58
View File
@@ -0,0 +1,58 @@
/*
* Web Worker for image optimization (P1). Offloads the expensive bitmap decode,
* resize, and JPEG/PNG compression to a background thread so the UI remains
* responsive while processing large photos.
*
* Uses OffscreenCanvas which is available in Chrome 69+, Firefox 105+, and
* Safari 16.4+. The main module (images.js) detects support at runtime and
* falls back to the main thread for older browsers.
*/
function fitIntoBox(originalWidth, originalHeight, maxWidth, maxHeight) {
const ratio = Math.min(maxWidth / originalWidth, maxHeight / originalHeight, 1);
return {
width: Math.round(originalWidth * ratio),
height: Math.round(originalHeight * ratio)
};
}
self.onmessage = async (event) => {
const { id, file, imageRules } = event.data;
try {
const imageBitmap = await createImageBitmap(file);
const maxWidth = imageRules?.maxWidthPx || imageBitmap.width;
const maxHeight = imageRules?.maxHeightPx || imageBitmap.height;
const { width, height } = fitIntoBox(imageBitmap.width, imageBitmap.height, maxWidth, maxHeight);
const canvas = new OffscreenCanvas(width, height);
const context = canvas.getContext('2d');
context.drawImage(imageBitmap, 0, 0, width, height);
imageBitmap.close();
const targetMimeType = file.type === 'image/png' ? 'image/png' : 'image/jpeg';
const quality = Math.min(Math.max((imageRules?.jpegQuality || 82) / 100, 0.2), 0.95);
const blob = await canvas.convertToBlob({ type: targetMimeType, quality });
if (!blob) {
throw new Error(`Failed to optimize image: ${file.name}`);
}
if (imageRules?.maxFileSizeBytes && blob.size > imageRules.maxFileSizeBytes) {
throw new Error(`Optimized image still exceeds limit: ${file.name}`);
}
self.postMessage({
id,
result: {
blob,
width,
height,
extension: targetMimeType === 'image/png' ? 'png' : 'jpg'
}
});
} catch (error) {
self.postMessage({ id, error: error.message });
}
};
+134
View File
@@ -0,0 +1,134 @@
/*
* Image optimization module (P1). When OffscreenCanvas is available the heavy
* pixel work runs in a dedicated Web Worker so the UI thread stays responsive
* during large-image processing. On browsers that lack OffscreenCanvas support
* (or when running inside a Worker is not possible) the module falls back to
* main-thread canvas operations.
*/
let worker = null;
let workerSupported = null;
function isWorkerSupported() {
if (workerSupported !== null) {
return workerSupported;
}
try {
/* OffscreenCanvas is required inside the Worker to draw without a DOM. */
workerSupported = typeof OffscreenCanvas !== 'undefined' && typeof Worker !== 'undefined';
} catch {
workerSupported = false;
}
return workerSupported;
}
function getWorker() {
if (!worker && isWorkerSupported()) {
worker = new Worker('/js/image-worker.js');
}
return worker;
}
/*
* Public entry point. Validates the file against the image rules, then delegates
* the actual resize/compress work to the worker or the main-thread fallback.
*/
export async function optimizeImage(file, imageRules) {
if (imageRules?.allowedMimeTypes?.length && !imageRules.allowedMimeTypes.includes(file.type)) {
throw new Error(`Unsupported file type: ${file.type}`);
}
if (imageRules?.oversizeBehavior === 'block' && file.size > imageRules.maxFileSizeBytes) {
throw new Error(`File exceeds limit: ${file.name}`);
}
const w = getWorker();
if (w) {
return optimizeInWorker(w, file, imageRules);
}
return optimizeOnMainThread(file, imageRules);
}
/* ── Worker path ────────────────────────────────────────────────────────── */
function optimizeInWorker(w, file, imageRules) {
return new Promise((resolve, reject) => {
const messageId = crypto.randomUUID();
function handler(event) {
if (event.data?.id !== messageId) {
return;
}
w.removeEventListener('message', handler);
w.removeEventListener('error', errorHandler);
if (event.data.error) {
reject(new Error(event.data.error));
} else {
resolve(event.data.result);
}
}
function errorHandler(event) {
w.removeEventListener('message', handler);
w.removeEventListener('error', errorHandler);
reject(new Error(event.message || 'Worker error'));
}
w.addEventListener('message', handler);
w.addEventListener('error', errorHandler);
w.postMessage({ id: messageId, file, imageRules });
});
}
/* ── Main-thread fallback ───────────────────────────────────────────────── */
async function optimizeOnMainThread(file, imageRules) {
const imageBitmap = await createImageBitmap(file);
const maxWidth = imageRules?.maxWidthPx || imageBitmap.width;
const maxHeight = imageRules?.maxHeightPx || imageBitmap.height;
const { width, height } = fitIntoBox(imageBitmap.width, imageBitmap.height, maxWidth, maxHeight);
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const context = canvas.getContext('2d');
context.drawImage(imageBitmap, 0, 0, width, height);
const targetMimeType = file.type === 'image/png' ? 'image/png' : 'image/jpeg';
const quality = Math.min(Math.max((imageRules?.jpegQuality || 82) / 100, 0.2), 0.95);
const blob = await new Promise((resolve) => {
canvas.toBlob(resolve, targetMimeType, quality);
});
if (!blob) {
throw new Error(`Failed to optimize image: ${file.name}`);
}
if (imageRules?.maxFileSizeBytes && blob.size > imageRules.maxFileSizeBytes) {
throw new Error(`Optimized image still exceeds limit: ${file.name}`);
}
return {
blob,
width,
height,
extension: targetMimeType === 'image/png' ? 'png' : 'jpg'
};
}
export function fitIntoBox(originalWidth, originalHeight, maxWidth, maxHeight) {
const ratio = Math.min(maxWidth / originalWidth, maxHeight / originalHeight, 1);
return {
width: Math.round(originalWidth * ratio),
height: Math.round(originalHeight * ratio)
};
}
+389
View File
@@ -0,0 +1,389 @@
/*
* Rendering module. All functions that manipulate the visible DOM live here.
* Each render function reads from the shared state and writes to the cached
* element references. The module has no side effects beyond DOM mutation.
*/
import { state, elements, getCurrentReport, getTemplateRecord } from './state.js';
import { createFieldNode } from './forms.js';
import { validateReport } from './validation.js';
import {
formatDateTime,
formatTime,
formatRelativeTime,
formatFileSize,
prettifyStatus,
badgeClassForTone
} from './utils.js';
import { t } from './i18n.js';
/* ── Top-level render orchestrator ──────────────────────────────────────── */
export function render(fieldCallbacks) {
renderTemplateSelector();
/* User workspace renders — only when the report editor DOM exists. */
if (elements.reportForm) {
renderReportList();
renderTemplateSummary();
renderCurrentReport(fieldCallbacks);
renderSyncSummary();
renderImagePolicy();
}
/* Admin workspace renders — only when the admin form DOM exists. */
if (elements.adminImageRulesForm) {
renderAdminImageRules();
}
}
/* ── Template selector ──────────────────────────────────────────────────── */
export function renderTemplateSelector() {
const currentValue = state.selectedTemplateCode;
elements.templateSelect.innerHTML = '';
if (!state.templatesCatalog.length) {
const option = document.createElement('option');
option.value = '';
option.textContent = t('noTemplatesCached');
elements.templateSelect.append(option);
return;
}
for (const template of state.templatesCatalog) {
const option = document.createElement('option');
option.value = template.code;
option.textContent = `${template.name} (v${template.activeVersion})`;
option.selected = template.code === currentValue;
elements.templateSelect.append(option);
}
}
/* ── Report list (F4 — with search & status filter) ─────────────────────── */
export function renderReportList() {
if (!elements.reportList) {
return;
}
elements.reportList.innerHTML = '';
/* Apply search query and status filter. */
let filtered = state.reports;
if (state.reportSearchQuery) {
const q = state.reportSearchQuery.toLowerCase();
filtered = filtered.filter((r) => {
const num = (r.answers?.reportNumber || r.reportNumber || '').toLowerCase();
const title = (r.title || '').toLowerCase();
return num.includes(q) || title.includes(q);
});
}
if (state.reportFilterStatus) {
filtered = filtered.filter((r) => r.status === state.reportFilterStatus);
}
elements.reportCount.textContent = String(filtered.length);
if (!filtered.length) {
const empty = document.createElement('p');
empty.className = 'field-help';
empty.textContent = state.reports.length ? 'No reports match the current filter.' : t('noLocalReports');
elements.reportList.append(empty);
return;
}
for (const report of filtered) {
const fragment = elements.reportListItemTemplate.content.cloneNode(true);
const button = fragment.querySelector('.report-list-item');
const title = fragment.querySelector('.report-list-item__title');
const statusEl = fragment.querySelector('.report-list-item__status');
const meta = fragment.querySelector('.report-list-item__meta');
title.textContent = report.answers.reportNumber || report.reportNumber;
statusEl.textContent = prettifyStatus(report.status);
statusEl.classList.add(`status-${report.status}`);
meta.textContent = `${report.title} • Updated ${formatDateTime(report.updatedAt)}`;
if (report.id === state.currentReportId) {
button.classList.add('is-active');
}
button.dataset.reportId = report.id;
elements.reportList.append(button);
}
}
/* ── Template summary cards ─────────────────────────────────────────────── */
export function renderTemplateSummary() {
if (!elements.summaryTemplate) {
return;
}
const report = getCurrentReport();
const templateCode = report?.templateCode || state.selectedTemplateCode;
const catalogEntry = state.templatesCatalog.find((item) => item.code === templateCode);
if (!catalogEntry) {
elements.summaryTemplate.textContent = 'Not loaded';
elements.summaryVersion.textContent = 'Version -';
return;
}
elements.summaryTemplate.textContent = catalogEntry.name;
elements.summaryVersion.textContent = report
? `Version ${report.templateVersion}`
: `Version ${catalogEntry.activeVersion}`;
}
export function renderSyncSummary() {
if (!elements.syncHeadline) {
return;
}
elements.syncHeadline.textContent = state.lastSyncAt
? `Last sync ${formatRelativeTime(state.lastSyncAt)}`
: 'No sync yet';
elements.syncDetail.textContent = state.lastSyncAt
? `Cached template data from ${formatDateTime(state.lastSyncAt)}`
: 'Templates are cached locally after the first successful sync.';
}
/* ── Image policy ───────────────────────────────────────────────────────── */
export function renderImagePolicy() {
if (!elements.imagePolicyText) {
return;
}
if (!state.imageRules) {
elements.imagePolicyText.textContent = t('imagePolicyHint');
return;
}
elements.imagePolicyText.textContent = `${state.imageRules.name}: ${state.imageRules.allowedMimeTypes.join(
', '
)}, max ${formatFileSize(state.imageRules.maxFileSizeBytes)}, ${state.imageRules.maxWidthPx}x${state.imageRules.maxHeightPx}px, behavior ${state.imageRules.oversizeBehavior}.`;
}
/* ── Admin image rules ──────────────────────────────────────────────────── */
export function renderAdminImageRules() {
if (!elements.adminSyncState) {
return;
}
populateAdminImageRulesForm(state.imageRules);
if (!state.imageRules) {
elements.adminSyncState.textContent = t('noImageRulesLoaded');
elements.adminSyncState.className = 'badge badge-offline';
elements.adminPolicyCode.textContent = '-';
elements.adminPolicyMimeTypes.textContent = '-';
elements.adminPolicyOptimization.textContent = '-';
elements.adminPolicyLimits.textContent = '-';
return;
}
elements.adminSyncState.textContent = navigator.onLine ? t('liveConfig') : t('offlineCachedConfig');
elements.adminSyncState.className = `badge ${navigator.onLine ? 'badge-online' : 'badge-offline'}`;
elements.adminPolicyCode.textContent = state.imageRules.code;
elements.adminPolicyMimeTypes.textContent = state.imageRules.allowedMimeTypes.join(', ');
elements.adminPolicyOptimization.textContent = `${state.imageRules.oversizeBehavior}, JPEG ${state.imageRules.jpegQuality}%`;
elements.adminPolicyLimits.textContent = `${formatFileSize(state.imageRules.maxFileSizeBytes)}, ${state.imageRules.maxWidthPx}x${state.imageRules.maxHeightPx}px, ${state.imageRules.maxAttachmentsPerField} attachment(s)`;
}
export function populateAdminImageRulesForm(imageRules) {
if (!imageRules || !elements.adminPolicyName) {
return;
}
elements.adminPolicyName.value = imageRules.name || '';
elements.adminAllowedMimeTypes.value = (imageRules.allowedMimeTypes || []).join(', ');
elements.adminMaxFileSizeMb.value = String((Number(imageRules.maxFileSizeBytes || 0) / (1024 * 1024)).toFixed(1));
elements.adminMaxAttachmentsPerField.value = String(imageRules.maxAttachmentsPerField || 1);
elements.adminMaxWidthPx.value = String(imageRules.maxWidthPx || '');
elements.adminMaxHeightPx.value = String(imageRules.maxHeightPx || '');
elements.adminJpegQuality.value = String(imageRules.jpegQuality || '');
elements.adminOversizeBehavior.value = imageRules.oversizeBehavior || 'auto_optimize';
}
/* ── Current report ─────────────────────────────────────────────────────── */
/*
* `renderCurrentReport` accepts a `fieldCallbacks` object so the form module
* can invoke actions (field change, attach, remove) owned by the orchestrator
* without creating circular imports.
*/
export function renderCurrentReport(fieldCallbacks = {}) {
if (!elements.reportForm) {
return;
}
const report = getCurrentReport();
if (!report) {
elements.heroTitle.textContent = t('noReportSelected');
elements.heroSubtitle.textContent = t('noReportSelectedHint');
elements.reportStatusSelect.value = 'draft';
elements.reportStatusSelect.disabled = true;
elements.deleteReportButton.disabled = true;
if (elements.submitReportButton) elements.submitReportButton.disabled = true;
if (elements.exportReportButton) elements.exportReportButton.disabled = true;
elements.editorHint.textContent = 'Dynamic form rendering from template JSON';
elements.reportForm.innerHTML = `
<div class="empty-state">
<h3>No report open</h3>
<p>Choose a template and create a report to start editing locally.</p>
</div>
`;
renderMeta(null);
renderValidation();
return;
}
const template = getTemplateRecord(report.templateCode, report.templateVersion);
elements.reportStatusSelect.disabled = false;
elements.reportStatusSelect.value = report.status;
elements.deleteReportButton.disabled = false;
if (elements.submitReportButton) elements.submitReportButton.disabled = false;
if (elements.exportReportButton) elements.exportReportButton.disabled = false;
elements.heroTitle.textContent = report.answers.reportNumber || report.reportNumber;
elements.heroSubtitle.textContent = `${template?.name || report.templateCode} • Local draft bound to template version ${report.templateVersion}`;
elements.editorHint.textContent = `${state.currentAttachments.length} attachment(s) stored in IndexedDB for this report`;
if (!template) {
elements.reportForm.innerHTML = `
<div class="empty-state">
<h3>Template missing</h3>
<p>This draft is bound to template version ${report.templateVersion}, but that definition is not cached locally.</p>
</div>
`;
renderMeta(report);
renderValidation();
return;
}
elements.reportForm.innerHTML = '';
for (const section of template.definition.sections || []) {
const sectionNode = document.createElement('section');
sectionNode.className = 'template-section';
const heading = document.createElement('div');
heading.className = 'section-heading-row';
heading.innerHTML = `<h3>${escapeHtml(section.title)}</h3><span class="panel-note">${section.fields.length} field(s)</span>`;
sectionNode.append(heading);
const fieldGrid = document.createElement('div');
fieldGrid.className = 'field-grid';
for (const field of section.fields || []) {
fieldGrid.append(createFieldNode(field, report, {
state,
onFieldChange: fieldCallbacks.onFieldChange || (() => {}),
onAttachFiles: fieldCallbacks.onAttachFiles || (() => {}),
onRemoveAttachment: fieldCallbacks.onRemoveAttachment || (() => {})
}));
}
sectionNode.append(fieldGrid);
elements.reportForm.append(sectionNode);
}
renderMeta(report);
renderValidation();
}
/* ── Meta & validation ──────────────────────────────────────────────────── */
export function renderMeta(report) {
if (!elements.reportMeta) {
return;
}
const values = report
? [
report.id,
`${report.templateCode} v${report.templateVersion}`,
formatDateTime(report.createdAt),
formatDateTime(report.updatedAt)
]
: ['-', '-', '-', '-'];
elements.reportMeta.querySelectorAll('dd').forEach((node, index) => {
node.textContent = values[index];
});
}
export function renderValidation() {
if (!elements.validationHeadline) {
return;
}
const report = getCurrentReport();
if (!report) {
elements.validationHeadline.textContent = t('noReportSelected');
elements.validationDetail.textContent = 'Draft validation will appear here.';
elements.validationList.innerHTML = '<li>No report selected.</li>';
return;
}
const template = getTemplateRecord(report.templateCode, report.templateVersion);
if (!template) {
elements.validationHeadline.textContent = t('templateUnavailable');
elements.validationDetail.textContent = t('validationNeedsTemplate');
elements.validationList.innerHTML = `<li>${t('templateMissing')}</li>`;
return;
}
const issues = validateReport(report, template, state.currentAttachments);
if (!issues.length) {
elements.validationHeadline.textContent = t('readyForExport');
elements.validationDetail.textContent = t('noBlockingIssues');
elements.validationList.innerHTML = `<li>${t('noValidationIssues')}</li>`;
return;
}
elements.validationHeadline.textContent = t('issueCount', issues.length);
elements.validationDetail.textContent =
report.status === 'ready_for_export' ? t('issueReadyWarning') : t('issueDraftHint');
elements.validationList.innerHTML = '';
for (const issue of issues) {
const item = document.createElement('li');
item.textContent = issue;
elements.validationList.append(item);
}
}
/* ── Badge helpers ──────────────────────────────────────────────────────── */
export function updateConnectionBadge() {
if (navigator.onLine) {
elements.connectionBadge.textContent = t('online');
elements.connectionBadge.className = 'badge badge-online';
} else {
elements.connectionBadge.textContent = t('offline');
elements.connectionBadge.className = 'badge badge-offline';
}
}
export function updateSaveBadge(text, tone) {
elements.saveBadge.textContent = text;
elements.saveBadge.className = `badge ${badgeClassForTone(tone)}`;
}
/* ── Helpers ────────────────────────────────────────────────────────────── */
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
+58
View File
@@ -0,0 +1,58 @@
/*
* Centralized application state. Every module imports the same `state` object so
* all shared data lives in one place. The `elements` object caches DOM references
* established during initialization.
*/
export const state = {
db: null,
reports: [],
templatesCatalog: [],
templateDefinitions: new Map(),
lookups: new Map(),
imageRules: null,
appConfig: new Map(),
currentReportId: null,
currentAttachments: [],
selectedTemplateCode: null,
saveState: 'idle',
saveTimer: null,
autosaveIntervalId: null,
lastSyncAt: null,
/* Dirty flag: true when the current report has unsaved field changes (P6). */
dirty: false,
/* Search/filter state for the report list (F4). */
reportSearchQuery: '',
reportFilterStatus: ''
};
/* Cached DOM element references, populated once during init. */
export const elements = {};
/* ── State accessors ────────────────────────────────────────────────────── */
export function getCurrentReport() {
return state.reports.find((item) => item.id === state.currentReportId) || null;
}
export function getTemplateRecord(code, version) {
if (!code || version == null) {
return null;
}
const { makeTemplateKey } = stateHelpers;
return state.templateDefinitions.get(makeTemplateKey(code, version)) || null;
}
/*
* Externalizing makeTemplateKey as a helper avoids a circular import — utils.js
* cannot import state.js. Instead state.js imports nothing from utils; callers
* that need both can reference stateHelpers.makeTemplateKey.
*/
const stateHelpers = {
makeTemplateKey(code, version) {
return `${code}::${version}`;
}
};
export { stateHelpers };
+150
View File
@@ -0,0 +1,150 @@
/*
* Utility functions used across multiple frontend modules. These are pure
* functions with no side effects and no dependency on application state.
*/
/* ── Formatting ─────────────────────────────────────────────────────────── */
export function prettifyStatus(status) {
return status
.split('_')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
}
export function formatDateTime(value) {
return new Date(value).toLocaleString();
}
export function formatTime(value) {
return new Date(value).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
export function formatRelativeTime(value) {
const diffMs = Date.now() - new Date(value).getTime();
const diffMinutes = Math.round(diffMs / 60000);
if (diffMinutes < 1) {
return 'just now';
}
if (diffMinutes < 60) {
return `${diffMinutes} minute(s) ago`;
}
const diffHours = Math.round(diffMinutes / 60);
if (diffHours < 24) {
return `${diffHours} hour(s) ago`;
}
return `${Math.round(diffHours / 24)} day(s) ago`;
}
export function formatFileSize(bytes) {
if (bytes < 1024) {
return `${bytes} B`;
}
if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)} KB`;
}
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
/* ── Naming & Sanitization ──────────────────────────────────────────────── */
export function sanitizeForFilename(value) {
return String(value || 'report')
.trim()
.replace(/\s+/g, '-')
.replace(/[^a-zA-Z0-9-_]/g, '')
.slice(0, 40);
}
export function generateReportNumber() {
const now = new Date();
const stamp = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(
now.getDate()
).padStart(2, '0')}-${String(now.getHours()).padStart(2, '0')}${String(
now.getMinutes()
).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')}`;
return `POC-${stamp}`;
}
export function buildGeneratedFilename(report, field, sequence, extension) {
const reportNumber = sanitizeForFilename(report.answers.reportNumber || report.reportNumber);
const sectionCode = sanitizeForFilename(field.id).slice(0, 10).toUpperCase();
return `${reportNumber}_${sectionCode}_${String(sequence).padStart(3, '0')}.${extension}`;
}
/* ── Badge helpers ──────────────────────────────────────────────────────── */
export function badgeClassForTone(tone) {
if (tone === 'success') {
return 'badge-online';
}
if (tone === 'error') {
return 'badge-error';
}
if (tone === 'warning') {
return 'badge-offline';
}
return 'badge-neutral';
}
/* ── Template helpers ───────────────────────────────────────────────────── */
export function makeTemplateKey(code, version) {
return `${code}::${version}`;
}
/*
* Multiple versions of the same template can exist in local cache because old
* drafts remain bound to the version they started with. The catalog therefore
* picks the newest version per template code for the creation UI while keeping
* older records available in the version lookup map.
*/
export function deriveTemplateCatalog(templateRows) {
const byCode = new Map();
for (const row of templateRows) {
const existing = byCode.get(row.code);
const shouldReplace = !existing || Number(row.version) > Number(existing.version);
if (shouldReplace) {
byCode.set(row.code, row);
}
}
return Array.from(byCode.values())
.sort((left, right) => left.name.localeCompare(right.name))
.map((item) => ({
code: item.code,
name: item.name,
description: item.description,
activeVersion: item.version,
publishedAt: item.publishedAt
}));
}
/* ── General ────────────────────────────────────────────────────────────── */
/*
* Debounce helper. Returns a wrapper that delays invocation until `ms`
* milliseconds of inactivity have passed, preventing expensive operations
* (such as a full form re-render) from running on every keystroke.
*/
export function debounce(fn, ms) {
let timer = null;
return function debounced(...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), ms);
};
}
+100
View File
@@ -0,0 +1,100 @@
/*
* Report and image-rules validation. These are pure functions with no DOM or
* state dependencies so they can be tested independently and kept in sync with
* server-side validation in src/routes/configRoutes.js (A7).
*/
/* ── Report validation ──────────────────────────────────────────────────── */
/*
* Returns an array of human-readable issue strings. The caller decides whether
* issues are informational or blocking.
*/
export function validateReport(report, template, attachments) {
const issues = [];
for (const section of template.definition.sections || []) {
for (const field of section.fields || []) {
const value = report.answers[field.id];
const required = Boolean(field.required || evaluateRequiredWhen(field.requiredWhen, report.answers));
if (field.type === 'attachment') {
const fieldAttachments = attachments.filter((item) => item.fieldId === field.id);
if (required && fieldAttachments.length === 0) {
issues.push(`${field.label}: at least one image is required.`);
}
continue;
}
if (required && isBlankValue(value, field.type)) {
issues.push(`${field.label}: value is required.`);
}
if (field.type === 'number' && value !== '' && value != null) {
if (Number.isNaN(Number(value))) {
issues.push(`${field.label}: number is invalid.`);
}
if (field.validation?.min !== undefined && Number(value) < field.validation.min) {
issues.push(`${field.label}: must be at least ${field.validation.min}.`);
}
}
}
}
return issues;
}
/* ── Image-rules validation (mirrors server-side logic in configRoutes) ── */
export function validateImageRulesPayload(payload) {
if (!payload.name) {
return 'Policy name is required';
}
if (!payload.allowedMimeTypes.length) {
return 'Add at least one MIME type';
}
if (!Number.isFinite(payload.maxFileSizeBytes) || payload.maxFileSizeBytes <= 0) {
return 'Max file size must be greater than 0';
}
if (!Number.isInteger(payload.maxWidthPx) || payload.maxWidthPx <= 0) {
return 'Max width must be a positive number';
}
if (!Number.isInteger(payload.maxHeightPx) || payload.maxHeightPx <= 0) {
return 'Max height must be a positive number';
}
if (!Number.isInteger(payload.jpegQuality) || payload.jpegQuality < 1 || payload.jpegQuality > 100) {
return 'JPEG quality must be between 1 and 100';
}
if (!Number.isInteger(payload.maxAttachmentsPerField) || payload.maxAttachmentsPerField <= 0) {
return 'Max attachments must be a positive number';
}
return null;
}
/* ── Helpers ────────────────────────────────────────────────────────────── */
export function evaluateRequiredWhen(requiredWhen, answers) {
if (!requiredWhen?.field) {
return false;
}
return answers[requiredWhen.field] === requiredWhen.equals;
}
export function isBlankValue(value, type) {
if (type === 'checkbox') {
return false;
}
return value === '' || value == null;
}
+17
View File
@@ -0,0 +1,17 @@
{
"name": "Check List Proof of Concept",
"short_name": "Check List",
"start_url": "/",
"display": "standalone",
"background_color": "#f3efe6",
"theme_color": "#f3efe6",
"description": "Offline-first proof of concept for template-driven quality inspection reports.",
"icons": [
{
"src": "/icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"
}
]
}
+48
View File
@@ -0,0 +1,48 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#f3efe6" />
<title>Check List Portal</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body class="portal-body">
<!--
The portal intentionally acts as a simple role chooser rather than a real
authentication page. It separates user and admin entry points in the PoC
without committing the project to a security model too early.
-->
<main class="portal-shell">
<section class="portal-hero panel">
<p class="eyebrow">Check List Access</p>
<h1>Choose workspace</h1>
<p class="portal-copy">
Use the operator workspace for quality reports and the administrator workspace for configuration.
</p>
</section>
<section class="portal-grid">
<!-- Direct operator entry for report creation and local draft work. -->
<a class="portal-card panel" href="/user">
<p class="summary-label">User area</p>
<h2>Operator workspace</h2>
<p class="portal-copy">
Create reports, work offline, attach images, and manage local drafts.
</p>
<span class="button button-primary portal-button">Open user area</span>
</a>
<!-- Direct administrator entry for centrally managed configuration. -->
<a class="portal-card panel" href="/admin">
<p class="summary-label">Admin area</p>
<h2>Administrator workspace</h2>
<p class="portal-copy">
Maintain image requirements and other centrally managed configuration.
</p>
<span class="button button-secondary portal-button">Open admin area</span>
</a>
</section>
</main>
</body>
</html>
+638
View File
@@ -0,0 +1,638 @@
:root {
/*
* The visual system uses a warm industrial palette instead of generic neutral
* SaaS colors. The goal is to make the PoC feel closer to an operational tool
* used in inspection environments than to a stock admin dashboard.
*/
--bg: #f3efe6;
--bg-strong: #e8dcc7;
--panel: rgba(255, 252, 247, 0.92);
--panel-border: rgba(93, 67, 35, 0.16);
--text: #1c1a18;
--muted: #685f53;
--accent: #9d3d2e;
--accent-strong: #7f2c20;
--accent-soft: #f4d2bf;
--success: #25624c;
--warning: #8a6119;
--danger: #8b2e34;
--shadow: 0 20px 50px rgba(77, 48, 18, 0.12);
--radius-lg: 24px;
--radius-md: 16px;
--radius-sm: 12px;
--font-ui: "Aptos", "Segoe UI Variable", "Segoe UI", sans-serif;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
min-height: 100%;
background:
radial-gradient(circle at top left, rgba(255, 255, 255, 0.7), transparent 35%),
linear-gradient(135deg, #f8f2e8 0%, #ead9bb 44%, #e9d8c5 100%);
color: var(--text);
font-family: var(--font-ui);
}
body {
min-height: 100vh;
}
.portal-body {
display: grid;
place-items: center;
padding: 24px;
}
button,
input,
select,
textarea {
font: inherit;
}
.app-shell {
/*
* The main layout keeps navigation and report context visible at the same time.
* This is important for a checklist workflow where users often switch reports
* and need immediate awareness of save state, sync state, and current draft.
*/
display: grid;
grid-template-columns: minmax(300px, 360px) minmax(0, 1fr);
min-height: 100vh;
gap: 20px;
padding: 20px;
}
.panel {
border: 1px solid var(--panel-border);
border-radius: var(--radius-lg);
background: var(--panel);
box-shadow: var(--shadow);
backdrop-filter: blur(12px);
}
.sidebar {
display: flex;
flex-direction: column;
padding: 24px;
gap: 22px;
}
.brand-block h1,
.hero h2,
.section-heading-row h2,
.empty-state h3,
.validation-block h3,
.attachment-policy h3 {
margin: 0;
}
.eyebrow {
margin: 0 0 8px;
color: var(--accent-strong);
letter-spacing: 0.16em;
text-transform: uppercase;
font-size: 0.74rem;
font-weight: 700;
}
.lede,
.hero-copy,
.panel-note,
.summary-note,
.policy-copy,
.empty-state p,
.meta-list dd,
.validation-list,
.report-list-item__meta {
color: var(--muted);
}
.sidebar-section {
display: grid;
gap: 12px;
}
.sidebar-link {
display: inline-flex;
justify-content: center;
text-decoration: none;
}
.sidebar-link.is-active {
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-strong) 100%);
color: #fff8f0;
}
.portal-shell {
/*
* The chooser page is intentionally sparse. Its only job is to separate entry
* points for operators and administrators without forcing a more complex auth
* design into the PoC before roles and identity are finalized.
*/
width: min(1100px, 100%);
display: grid;
gap: 24px;
}
.portal-hero {
padding: 32px;
}
.portal-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20px;
}
.portal-card {
display: grid;
gap: 16px;
padding: 28px;
text-decoration: none;
color: inherit;
}
.portal-copy {
margin: 0;
color: var(--muted);
line-height: 1.5;
}
.portal-button {
width: fit-content;
}
.grow-section {
min-height: 0;
flex: 1;
}
.status-row,
.section-heading-row,
.hero-actions,
.field-header,
.attachment-toolbar,
.report-list-item__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.section-heading-row {
align-items: baseline;
}
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-radius: 999px;
font-size: 0.78rem;
font-weight: 700;
}
.badge-neutral {
background: rgba(28, 26, 24, 0.08);
color: var(--text);
}
.badge-online,
.status-in_progress,
.status-ready_for_export {
background: rgba(37, 98, 76, 0.12);
color: var(--success);
}
.badge-offline,
.status-draft,
.status-archived {
background: rgba(138, 97, 25, 0.12);
color: var(--warning);
}
.badge-error,
.status-exported {
background: rgba(139, 46, 52, 0.12);
color: var(--danger);
}
.button {
border: 0;
border-radius: 999px;
padding: 12px 16px;
font-weight: 700;
cursor: pointer;
transition: transform 160ms ease, box-shadow 160ms ease, background 160ms ease;
}
.button:hover {
transform: translateY(-1px);
}
.button-primary {
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-strong) 100%);
color: #fff8f0;
box-shadow: 0 12px 24px rgba(157, 61, 46, 0.25);
}
.button-secondary {
background: rgba(28, 26, 24, 0.08);
color: var(--text);
}
.button-ghost {
background: transparent;
color: var(--danger);
border: 1px solid rgba(139, 46, 52, 0.18);
}
.button-small {
padding: 8px 12px;
font-size: 0.84rem;
}
.workspace {
display: grid;
gap: 20px;
}
.workspace-view {
/*
* User and admin workspaces share one HTML document. Hidden sections let the
* route control which workspace is visible while still reusing common styling
* and keeping asset delivery simple.
*/
display: none;
gap: 20px;
}
.workspace-view-active {
display: grid;
}
.hero {
display: flex;
justify-content: space-between;
gap: 20px;
padding: 24px 28px;
overflow: hidden;
position: relative;
}
.hero::after {
content: "";
position: absolute;
inset: auto -80px -80px auto;
width: 220px;
height: 220px;
border-radius: 50%;
background: radial-gradient(circle, rgba(157, 61, 46, 0.18), transparent 70%);
}
.hero-actions {
align-items: end;
flex-wrap: wrap;
}
.status-picker {
display: grid;
gap: 6px;
min-width: 220px;
}
.summary-grid,
.editor-grid {
/*
* These grids create a consistent rhythm between overview cards and working
* panels so the operator can scan status quickly before dropping into detail.
*/
display: grid;
gap: 20px;
}
.summary-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.summary-card {
padding: 20px 22px;
}
.accent-card {
background: linear-gradient(145deg, rgba(157, 61, 46, 0.12), rgba(255, 252, 247, 0.96));
}
.summary-label,
.field-label,
.meta-list dt {
margin: 0 0 8px;
color: var(--muted);
font-size: 0.84rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.editor-grid {
grid-template-columns: minmax(0, 1.7fr) minmax(300px, 380px);
}
.editor-panel,
.inspector-panel {
padding: 24px;
}
.admin-form {
margin-top: 16px;
}
.admin-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.report-form {
/*
* The form styles are generic on purpose because fields are generated from
* template JSON. The same primitives must support report editing and admin
* configuration without each field type needing a dedicated page-specific skin.
*/
display: grid;
gap: 20px;
margin-top: 16px;
}
.template-section {
display: grid;
gap: 16px;
padding: 20px;
border-radius: var(--radius-md);
background: rgba(243, 239, 230, 0.68);
border: 1px solid rgba(93, 67, 35, 0.12);
}
.field-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.field {
display: grid;
gap: 8px;
}
.field-full {
grid-column: 1 / -1;
}
.field-header {
align-items: baseline;
}
.required-pill {
color: var(--accent-strong);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.text-input,
.select-input,
.text-area,
.file-input {
width: 100%;
border: 1px solid rgba(93, 67, 35, 0.18);
background: rgba(255, 255, 255, 0.82);
color: var(--text);
border-radius: 14px;
padding: 12px 14px;
}
.text-input:focus,
.select-input:focus,
.text-area:focus,
.file-input:focus {
outline: 2px solid rgba(157, 61, 46, 0.22);
outline-offset: 2px;
}
.text-area {
min-height: 120px;
resize: vertical;
}
.checkbox-row {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.82);
border: 1px solid rgba(93, 67, 35, 0.18);
}
.field-help {
margin: 0;
font-size: 0.86rem;
color: var(--muted);
}
.report-list {
display: grid;
gap: 10px;
max-height: 420px;
overflow: auto;
padding-right: 4px;
}
/* F4 — Search and filter controls above the report list */
.report-filter-row {
display: grid;
grid-template-columns: 1fr auto;
gap: 8px;
}
.text-input-small,
.select-input-small {
padding: 8px 12px;
font-size: 0.88rem;
border-radius: 12px;
}
.select-input-small {
min-width: 120px;
}
.report-list-item {
width: 100%;
text-align: left;
border: 1px solid rgba(93, 67, 35, 0.12);
background: rgba(255, 255, 255, 0.74);
border-radius: 16px;
padding: 14px;
display: grid;
gap: 8px;
}
.report-list-item.is-active {
border-color: rgba(157, 61, 46, 0.36);
box-shadow: inset 0 0 0 1px rgba(157, 61, 46, 0.16);
background: rgba(244, 210, 191, 0.36);
}
.report-list-item__title {
font-size: 0.96rem;
}
.muted-count {
color: var(--muted);
font-size: 0.88rem;
}
.empty-state {
display: grid;
place-items: center;
min-height: 240px;
text-align: center;
padding: 24px;
border-radius: var(--radius-md);
background: rgba(243, 239, 230, 0.68);
border: 1px dashed rgba(93, 67, 35, 0.24);
}
.meta-list {
display: grid;
gap: 14px;
margin: 0;
}
.meta-list div {
display: grid;
gap: 4px;
padding-bottom: 12px;
border-bottom: 1px solid rgba(93, 67, 35, 0.1);
}
.meta-list dd {
margin: 0;
}
.validation-block,
.attachment-policy {
margin-top: 26px;
}
.validation-list {
padding-left: 18px;
}
.validation-list li + li {
margin-top: 8px;
}
.attachment-list {
display: grid;
gap: 12px;
}
.attachment-card {
/*
* Attachments need enough visual weight to confirm that a photo is really tied
* to a report item. The card layout reserves space for preview, metadata, and
* removal action without requiring a modal or separate gallery screen.
*/
display: grid;
grid-template-columns: 88px minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
padding: 12px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.86);
border: 1px solid rgba(93, 67, 35, 0.12);
}
.attachment-preview {
width: 88px;
height: 88px;
border-radius: 12px;
object-fit: cover;
background: rgba(93, 67, 35, 0.08);
}
.attachment-card__copy {
display: grid;
gap: 4px;
min-width: 0;
}
.attachment-card__copy strong,
.attachment-card__copy span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 1180px) {
/*
* Tablet and narrow laptop layouts collapse the two-column structure into a
* single column so the editing surface remains usable without horizontal scroll.
*/
.app-shell,
.editor-grid,
.summary-grid,
.portal-grid {
grid-template-columns: 1fr;
}
.sidebar {
min-height: auto;
}
.report-list {
max-height: 260px;
}
}
@media (max-width: 760px) {
/*
* Mobile layout prioritizes single-column readability and larger preview areas.
* This matters because one of the project requirements is viable use on phones
* where camera capture and image attachment happen directly in the browser.
*/
.app-shell {
padding: 14px;
gap: 14px;
}
.hero,
.hero-actions,
.field-grid,
.attachment-card {
grid-template-columns: 1fr;
display: grid;
}
.field-grid {
gap: 14px;
}
.attachment-card {
grid-template-columns: 1fr;
}
.attachment-preview {
width: 100%;
height: 180px;
}
}
+118
View File
@@ -0,0 +1,118 @@
const CACHE_NAME = 'check-list-poc-v3';
const DYNAMIC_CACHE_LIMIT = 50;
const APP_SHELL = [
'/',
'/user.html',
'/admin.html',
'/styles.css',
'/app.js',
'/manifest.webmanifest',
'/js/constants.js',
'/js/state.js',
'/js/i18n.js',
'/js/utils.js',
'/js/db.js',
'/js/api.js',
'/js/validation.js',
'/js/images.js',
'/js/image-worker.js',
'/js/forms.js',
'/js/renderer.js',
'/js/export.js'
];
/*
* P5 — Bounded SW cache with LRU eviction. Static shell assets use cache-first.
* API requests use network-first with automatic pruning of old dynamic entries
* so the cache stays within DYNAMIC_CACHE_LIMIT.
*/
self.addEventListener('install', (event) => {
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL)));
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))
)
)
);
self.clients.claim();
});
self.addEventListener('fetch', (event) => {
const { request } = event;
if (request.method !== 'GET') {
return;
}
const url = new URL(request.url);
if (url.origin !== self.location.origin) {
return;
}
if (url.pathname.startsWith('/api/')) {
event.respondWith(networkFirst(request));
return;
}
event.respondWith(cacheFirst(request));
});
async function cacheFirst(request) {
const cache = await caches.open(CACHE_NAME);
const cached = await cache.match(request);
if (cached) {
return cached;
}
const response = await fetch(request);
cache.put(request, response.clone());
return response;
}
async function networkFirst(request) {
const cache = await caches.open(CACHE_NAME);
try {
const response = await fetch(request);
cache.put(request, response.clone());
await trimCache(cache, DYNAMIC_CACHE_LIMIT);
return response;
} catch {
const cached = await cache.match(request);
if (cached) {
return cached;
}
return new Response(JSON.stringify({ message: 'Offline and no cached response available.' }), {
status: 503,
headers: { 'Content-Type': 'application/json' }
});
}
}
/**
* P5 — Remove oldest entries once the cache exceeds the limit. Only dynamic
* (non-shell) entries are evicted so the app shell remains always available.
*/
async function trimCache(cache, maxEntries) {
const keys = await cache.keys();
if (keys.length <= maxEntries) {
return;
}
const shellSet = new Set(APP_SHELL.map((path) => new URL(path, self.location.origin).href));
const evictable = keys.filter((request) => !shellSet.has(request.url));
while (evictable.length + APP_SHELL.length > maxEntries && evictable.length > 0) {
await cache.delete(evictable.shift());
}
}
+194
View File
@@ -0,0 +1,194 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#f3efe6" />
<title>Check List PoC — User</title>
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<!--
Operator workspace: report creation, local draft editing, validation,
image attachments, submission, and CSV export.
-->
<div class="app-shell">
<aside class="sidebar panel">
<div class="brand-block">
<p class="eyebrow">Hybrid Inspection Reporting</p>
<h1>Check List</h1>
<p class="lede">
Offline-first proof of concept for template-driven quality reports.
</p>
</div>
<div class="sidebar-section">
<div class="status-row">
<span id="connectionBadge" class="badge badge-neutral">Checking connection</span>
<span id="saveBadge" class="badge badge-neutral">No changes</span>
</div>
<button id="syncTemplatesButton" class="button button-secondary" type="button">
Sync templates
</button>
</div>
<div class="sidebar-section">
<label class="field-label" for="templateSelect">Template</label>
<select id="templateSelect" class="select-input"></select>
<button id="createReportButton" class="button button-primary" type="button">
Create new report
</button>
</div>
<div class="sidebar-section">
<div class="section-heading-row sidebar-links-heading">
<h2>Access</h2>
<span class="muted-count">Direct links</span>
</div>
<a id="userAreaLink" class="button button-secondary sidebar-link is-active" href="/user">User area</a>
<a id="adminAreaLink" class="button button-secondary sidebar-link" href="/admin">Admin area</a>
<a class="button button-secondary sidebar-link" href="/">Back to portal</a>
</div>
<div class="sidebar-section grow-section">
<div class="section-heading-row">
<h2>Local reports</h2>
<span id="reportCount" class="muted-count">0</span>
</div>
<div class="report-filter-row">
<input id="reportSearchInput" class="text-input text-input-small" type="search" placeholder="Search reports" />
<select id="reportFilterSelect" class="select-input select-input-small">
<option value="">All statuses</option>
<option value="draft">Draft</option>
<option value="in_progress">In Progress</option>
<option value="ready_for_export">Ready for Export</option>
<option value="exported">Exported</option>
<option value="archived">Archived</option>
</select>
</div>
<div id="reportList" class="report-list"></div>
</div>
</aside>
<main class="workspace">
<section id="reportsWorkspace" class="workspace-view workspace-view-active">
<section class="hero panel">
<div>
<p class="eyebrow">Proof of concept frontend</p>
<h2 id="heroTitle">No report selected</h2>
<p id="heroSubtitle" class="hero-copy">
Start by syncing templates and creating a local draft.
</p>
</div>
<div class="hero-actions">
<label class="status-picker">
<span>Status</span>
<select id="reportStatusSelect" class="select-input">
<option value="draft">Draft</option>
<option value="in_progress">In Progress</option>
<option value="ready_for_export">Ready for Export</option>
<option value="exported">Exported</option>
<option value="archived">Archived</option>
</select>
</label>
<button id="submitReportButton" class="button button-secondary" type="button">
Submit
</button>
<button id="exportReportButton" class="button button-secondary" type="button">
Export CSV
</button>
<button id="deleteReportButton" class="button button-ghost" type="button">
Delete report
</button>
</div>
</section>
<section class="summary-grid">
<article class="summary-card panel accent-card">
<p class="summary-label">Template</p>
<strong id="summaryTemplate">Not loaded</strong>
<span id="summaryVersion" class="summary-note">Version -</span>
</article>
<article class="summary-card panel">
<p class="summary-label">Validation</p>
<strong id="validationHeadline">No report selected</strong>
<span id="validationDetail" class="summary-note">Draft validation will appear here.</span>
</article>
<article class="summary-card panel">
<p class="summary-label">Offline cache</p>
<strong id="syncHeadline">No sync yet</strong>
<span id="syncDetail" class="summary-note">Templates are cached locally after the first successful sync.</span>
</article>
</section>
<section class="editor-grid">
<section class="panel editor-panel">
<div class="section-heading-row">
<h2>Report editor</h2>
<span id="editorHint" class="panel-note">Dynamic form rendering from template JSON</span>
</div>
<form id="reportForm" class="report-form">
<div class="empty-state">
<h3>No report open</h3>
<p>Choose a template and create a report to start editing locally.</p>
</div>
</form>
</section>
<aside class="panel inspector-panel">
<div class="section-heading-row">
<h2>Inspector view</h2>
<span class="panel-note">Local draft summary</span>
</div>
<dl id="reportMeta" class="meta-list">
<div>
<dt>Report ID</dt>
<dd>-</dd>
</div>
<div>
<dt>Template</dt>
<dd>-</dd>
</div>
<div>
<dt>Created</dt>
<dd>-</dd>
</div>
<div>
<dt>Updated</dt>
<dd>-</dd>
</div>
</dl>
<div class="validation-block">
<h3>Validation issues</h3>
<ul id="validationList" class="validation-list">
<li>No report selected.</li>
</ul>
</div>
<div class="attachment-policy">
<h3>Image policy</h3>
<p id="imagePolicyText" class="policy-copy">
Load server configuration to see image limits and optimization rules.
</p>
</div>
</aside>
</section>
</section>
</main>
</div>
<template id="reportListItemTemplate">
<button class="report-list-item" type="button" data-report-id="">
<span class="report-list-item__header">
<strong class="report-list-item__title"></strong>
<span class="report-list-item__status badge"></span>
</span>
<span class="report-list-item__meta"></span>
</button>
</template>
<script type="module" src="/app.js"></script>
</body>
</html>
+2 -2
View File
@@ -55,14 +55,14 @@ async function testApi() {
for (const baseUrl of candidates) {
try {
const health = await getJson(`${baseUrl}/api/health`);
const health = await getJson(`${baseUrl}/api/v1/health`);
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');
const templates = await getJson(`${baseUrl}/api/templates`);
const templates = await getJson(`${baseUrl}/api/v1/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');
+60
View File
@@ -1,7 +1,15 @@
-- This schema supports the phase-1 hybrid proof of concept.
-- The database stores centrally managed configuration only: templates,
-- lookup values, image policy, export settings, and lightweight app flags.
-- Completed reports are intentionally excluded from the server in this phase;
-- they remain browser-local artifacts exported by the client.
CREATE DATABASE IF NOT EXISTS check_list CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE check_list;
-- Template identity stays separate from version rows so a single checklist can
-- publish multiple definitions over time while preserving a stable code.
CREATE TABLE IF NOT EXISTS templates (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
code VARCHAR(100) NOT NULL,
@@ -13,6 +21,9 @@ CREATE TABLE IF NOT EXISTS templates (
UNIQUE KEY uq_templates_code (code)
);
-- Each template version stores a full JSON definition snapshot. That keeps the
-- frontend contract simple because the client can render a form from one payload
-- without performing additional joins or reconstruction logic.
CREATE TABLE IF NOT EXISTS template_versions (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
template_id BIGINT UNSIGNED NOT NULL,
@@ -29,6 +40,9 @@ CREATE TABLE IF NOT EXISTS template_versions (
ON DELETE CASCADE
);
-- Lookup sets support dropdown-style fields in a normalized way. The template
-- JSON references the set by code, while individual values remain editable as
-- data rather than hardcoded frontend options.
CREATE TABLE IF NOT EXISTS lookup_sets (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
code VARCHAR(100) NOT NULL,
@@ -55,6 +69,9 @@ CREATE TABLE IF NOT EXISTS lookup_values (
ON DELETE CASCADE
);
-- Image rules are server-managed so administrators can tighten or relax client
-- behavior, such as size limits or attachment counts, without changing browser
-- code. The client reads the active row and enforces it locally.
CREATE TABLE IF NOT EXISTS image_rules (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
code VARCHAR(100) NOT NULL,
@@ -72,6 +89,9 @@ CREATE TABLE IF NOT EXISTS image_rules (
UNIQUE KEY uq_image_rules_code (code)
);
-- Export profiles represent the eventual shape of the generated ZIP/XLSX output.
-- The current PoC only reads this information, but separating it now makes later
-- export customization easier.
CREATE TABLE IF NOT EXISTS export_profiles (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
code VARCHAR(100) NOT NULL,
@@ -86,6 +106,9 @@ CREATE TABLE IF NOT EXISTS export_profiles (
UNIQUE KEY uq_export_profiles_code (code)
);
-- Small application settings that do not justify dedicated tables are stored as
-- JSON key/value pairs. This keeps the schema lean during the PoC phase while
-- still allowing centrally managed frontend behavior.
CREATE TABLE IF NOT EXISTS app_config (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
config_key VARCHAR(100) NOT NULL,
@@ -95,3 +118,40 @@ CREATE TABLE IF NOT EXISTS app_config (
PRIMARY KEY (id),
UNIQUE KEY uq_app_config_key (config_key)
);
-- Submitted reports are stored server-side for centralized review and archival.
-- The browser creates reports locally first; this table receives them when the
-- operator explicitly submits. The report_uuid links back to the browser-local
-- report ID so resubmissions are idempotent via ON DUPLICATE KEY UPDATE.
CREATE TABLE IF NOT EXISTS reports (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
report_uuid CHAR(36) NOT NULL,
report_number VARCHAR(100) NOT NULL,
template_code VARCHAR(100) NOT NULL,
template_version INT NOT NULL,
status ENUM('draft', 'in_progress', 'ready_for_export', 'exported', 'archived') NOT NULL DEFAULT 'draft',
answers_json JSON NOT NULL,
submitted_at DATETIME 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_reports_uuid (report_uuid),
KEY idx_reports_template (template_code, template_version),
KEY idx_reports_status (status)
);
-- The audit log captures every administrative mutation so the team can trace
-- when configuration changed and what the previous value was. Each row stores
-- the entity type, the entity identifier, the action, and JSON snapshots of
-- the old and new values.
CREATE TABLE IF NOT EXISTS audit_log (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
entity_type VARCHAR(100) NOT NULL,
entity_code VARCHAR(200) NOT NULL,
action VARCHAR(50) NOT NULL,
old_value_json JSON NULL,
new_value_json JSON NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_audit_entity (entity_type, entity_code)
);
+14
View File
@@ -1,5 +1,11 @@
-- Seed data provides one complete end-to-end example for the frontend:
-- a checklist template, lookup values, image policy, export profile, and a few
-- app-level settings. The goal is not exhaustive coverage but a realistic shape
-- that exercises the dynamic form renderer and admin configuration flow.
USE check_list;
-- Register the template shell first so later version rows can reference it.
INSERT INTO templates (code, name, description)
VALUES (
'incoming-inspection',
@@ -12,6 +18,9 @@ ON DUPLICATE KEY UPDATE
SET @template_id = (SELECT id FROM templates WHERE code = 'incoming-inspection');
-- The JSON payload below is intentionally close to the frontend contract. That
-- makes it easy to inspect how the browser creates fields, validation hints, and
-- attachment requirements without another transformation layer in the backend.
INSERT INTO template_versions (
template_id,
version_number,
@@ -126,6 +135,8 @@ ON DUPLICATE KEY UPDATE
definition_json = VALUES(definition_json),
published_at = VALUES(published_at);
-- Lookups are seeded separately because multiple templates may eventually reuse
-- the same option sets, such as pass/fail or standardized status lists.
INSERT INTO lookup_sets (code, name)
VALUES
('pass-fail', 'Pass/Fail'),
@@ -150,6 +161,7 @@ ON DUPLICATE KEY UPDATE
sort_order = VALUES(sort_order),
is_default = VALUES(is_default);
-- The image rule row is the configuration edited by the administrator UI.
INSERT INTO image_rules (
code,
name,
@@ -185,6 +197,7 @@ ON DUPLICATE KEY UPDATE
max_attachments_per_field = VALUES(max_attachments_per_field),
is_active = VALUES(is_active);
-- Export profile settings are placeholders for the later XLSX/ZIP phase.
INSERT INTO export_profiles (
code,
name,
@@ -211,6 +224,7 @@ ON DUPLICATE KEY UPDATE
include_export_timestamp = VALUES(include_export_timestamp),
is_active = VALUES(is_active);
-- App config values fine-tune the client without changing code.
INSERT INTO app_config (config_key, config_value_json)
VALUES
('autosave', '{"enabled": true, "intervalSeconds": 20}'),
+54 -7
View File
@@ -1,29 +1,76 @@
import cors from 'cors';
import express from 'express';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
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 reportRoutes from './routes/reportRoutes.js';
import templateRoutes from './routes/templateRoutes.js';
const app = express();
/*
* The application serves two concerns from the same Express process:
* 1. a versioned REST API (v1) used by the proof-of-concept frontend,
* 2. static frontend assets for the chooser portal and the app shell itself.
*
* All API endpoints live under /api/v1/ so the contract can evolve without
* breaking older clients. Keeping both concerns in one process keeps the PoC
* easy to run in Docker and avoids introducing an additional frontend dev
* server before the product shape is stable.
*/
const publicDir = fileURLToPath(new URL('../public', import.meta.url));
const userPagePath = path.join(publicDir, 'user.html');
const adminPagePath = path.join(publicDir, 'admin.html');
const portalPath = path.join(publicDir, 'portal.html');
app.use(cors());
app.use(express.json({ limit: '10mb' }));
app.get('/', (_req, res) => {
app.get('/api/v1', (_req, res) => {
res.json({
service: 'check-list-poc-api',
version: '0.1.0',
description: 'PoC API for template and configuration delivery.'
version: '0.2.0',
description: 'Versioned PoC API for template, configuration, and report management.'
});
});
app.use('/api/health', healthRoutes);
app.use('/api/templates', templateRoutes);
app.use('/api/lookups', lookupRoutes);
app.use('/api/config', configRoutes);
/*
* All API routes are grouped under /api/v1/. The version prefix ensures future
* breaking changes can be introduced on /api/v2/ without disrupting existing
* frontend deployments that still reference v1 contract shapes.
*/
app.use('/api/v1/health', healthRoutes);
app.use('/api/v1/templates', templateRoutes);
app.use('/api/v1/lookups', lookupRoutes);
app.use('/api/v1/config', configRoutes);
app.use('/api/v1/reports', reportRoutes);
/*
* The root route intentionally serves a neutral portal page. This gives the
* project distinct user and administrator entry points without introducing a
* full authentication flow yet.
*/
app.get('/', (_req, res) => {
res.sendFile(portalPath);
});
/*
* User and admin workspaces live in separate HTML files so each page only loads
* the markup it needs. The shared frontend JavaScript (app.js) detects which
* elements are present and binds behavior accordingly.
*/
app.get(['/user', '/user/'], (_req, res) => {
res.sendFile(userPagePath);
});
app.get(['/admin', '/admin/'], (_req, res) => {
res.sendFile(adminPagePath);
});
app.use(express.static(publicDir));
app.use(notFoundHandler);
app.use(errorHandler);
+11
View File
@@ -4,12 +4,23 @@ dotenv.config();
const requiredKeys = ['DB_HOST', 'DB_PORT', 'DB_NAME', 'DB_USER', 'DB_PASSWORD'];
/*
* Environment validation happens at module load time so configuration mistakes
* are discovered immediately. That is intentional because this service is small
* enough that there is no benefit in deferring a misconfiguration error until a
* later database call or request handler.
*/
for (const key of requiredKeys) {
if (!process.env[key]) {
throw new Error(`Missing required environment variable: ${key}`);
}
}
/*
* The exported env object becomes the single place where raw process variables
* are normalized into application-friendly types such as numbers. That keeps the
* rest of the codebase from repeating string-to-number conversion logic.
*/
export const env = {
port: Number(process.env.PORT || 3000),
db: {
+10
View File
@@ -2,6 +2,11 @@ import * as mariadb from 'mariadb';
import { env } from '../config/env.js';
/*
* One shared pool is enough for the current backend because the service is read-
* heavy and low volume. Centralizing pool creation here prevents each route or
* service module from opening its own connections and makes shutdown predictable.
*/
const pool = mariadb.createPool({
host: env.db.host,
port: env.db.port,
@@ -16,6 +21,11 @@ export async function query(sql, params = []) {
let connection;
try {
/*
* The helper deliberately exposes a low-level query primitive instead of a
* custom repository abstraction. For the PoC that keeps SQL visible and easy
* to reason about while still ensuring every query uses the same pool.
*/
connection = await pool.getConnection();
return await connection.query(sql, params);
} finally {
+35
View File
@@ -0,0 +1,35 @@
/*
* Parameter validation middleware. Each route parameter is checked against
* a safe pattern to prevent unexpected input from reaching database queries.
* The whitelist approach rejects obviously invalid identifiers early, keeping
* service-layer code cleaner.
*/
const SAFE_CODE_PATTERN = /^[a-zA-Z0-9_-]{1,100}$/;
const SAFE_UUID_PATTERN = /^[a-f0-9-]{36}$/;
export function validateParam(paramName, { pattern = null } = {}) {
const resolvedPattern = pattern || (paramName.toLowerCase().includes('id') ? SAFE_UUID_PATTERN : SAFE_CODE_PATTERN);
return (req, res, next) => {
const value = req.params[paramName];
if (!value || !resolvedPattern.test(value)) {
return res.status(400).json({ message: `Invalid parameter: ${paramName}` });
}
next();
};
}
export function validateNumericParam(paramName) {
return (req, res, next) => {
const value = Number(req.params[paramName]);
if (!Number.isFinite(value) || value < 0) {
return res.status(400).json({ message: `Invalid numeric parameter: ${paramName}` });
}
next();
};
}
+113 -2
View File
@@ -3,22 +3,128 @@ import { Router } from 'express';
import {
getAppConfig,
getExportProfile,
getImageRules
getImageRules,
updateImageRules
} from '../services/configService.js';
import { logAuditEvent } from '../services/auditService.js';
import { asyncHandler } from '../utils/asyncHandler.js';
import { configCache } from '../services/cacheService.js';
const router = Router();
/*
* Image-rules validation is shared between server and admin frontend. The server
* acts as the final authority while the client validates proactively to give the
* administrator immediate feedback before the round-trip.
*/
function validateImageRulesPayload(payload) {
const oversizeBehaviors = ['auto_optimize', 'warn_then_optimize', 'block'];
const allowedMimeTypes = Array.isArray(payload.allowedMimeTypes)
? payload.allowedMimeTypes.filter((value) => typeof value === 'string' && value.trim())
: [];
if (!payload.name || typeof payload.name !== 'string') {
return 'Image policy name is required.';
}
if (!allowedMimeTypes.length) {
return 'At least one allowed MIME type is required.';
}
if (!Number.isInteger(payload.maxFileSizeBytes) || payload.maxFileSizeBytes <= 0) {
return 'Maximum file size must be a positive integer.';
}
if (!Number.isInteger(payload.maxWidthPx) || payload.maxWidthPx <= 0) {
return 'Maximum width must be a positive integer.';
}
if (!Number.isInteger(payload.maxHeightPx) || payload.maxHeightPx <= 0) {
return 'Maximum height must be a positive integer.';
}
if (!Number.isInteger(payload.jpegQuality) || payload.jpegQuality < 1 || payload.jpegQuality > 100) {
return 'JPEG quality must be an integer between 1 and 100.';
}
if (!oversizeBehaviors.includes(payload.oversizeBehavior)) {
return 'Oversize behavior is invalid.';
}
if (!Number.isInteger(payload.maxAttachmentsPerField) || payload.maxAttachmentsPerField <= 0) {
return 'Maximum attachments per field must be a positive integer.';
}
return null;
}
router.get(
'/image-rules',
asyncHandler(async (_req, res) => {
const cached = configCache.get('image-rules');
if (cached) {
return res.json(cached);
}
const imageRules = await getImageRules();
if (!imageRules) {
return res.status(404).json({ message: 'Image rules not found.' });
}
res.json(imageRules);
configCache.set('image-rules', imageRules);
return res.json(imageRules);
})
);
router.put(
'/image-rules',
asyncHandler(async (req, res) => {
/*
* Normalize incoming values before validation so the API can accept the
* browser form payload in a predictable shape. The frontend sends numbers as
* form values, but they still arrive over HTTP as strings until coerced.
*/
const payload = {
name: req.body?.name?.trim(),
allowedMimeTypes: Array.isArray(req.body?.allowedMimeTypes)
? req.body.allowedMimeTypes.map((value) => String(value).trim()).filter(Boolean)
: [],
maxFileSizeBytes: Number(req.body?.maxFileSizeBytes),
maxWidthPx: Number(req.body?.maxWidthPx),
maxHeightPx: Number(req.body?.maxHeightPx),
jpegQuality: Number(req.body?.jpegQuality),
oversizeBehavior: req.body?.oversizeBehavior,
maxAttachmentsPerField: Number(req.body?.maxAttachmentsPerField)
};
const validationMessage = validateImageRulesPayload(payload);
if (validationMessage) {
return res.status(400).json({ message: validationMessage });
}
/* Capture the current value before mutation for the audit trail. */
const previousRules = await getImageRules();
const imageRules = await updateImageRules(payload);
if (!imageRules) {
return res.status(404).json({ message: 'Image rules not found.' });
}
configCache.invalidate('image-rules');
await logAuditEvent({
entityType: 'image_rules',
entityCode: imageRules.code,
action: 'update',
oldValue: previousRules,
newValue: imageRules
});
return res.json(imageRules);
})
);
@@ -38,6 +144,11 @@ router.get(
router.get(
'/app-config',
asyncHandler(async (_req, res) => {
/*
* Generic application configuration is kept as a simple key/value list in
* the PoC. This avoids hardcoding small behavioral settings in the frontend
* while still keeping the schema easy to inspect and evolve.
*/
const config = await getAppConfig();
res.json({ items: config });
})
+5
View File
@@ -8,6 +8,11 @@ const router = Router();
router.get(
'/',
asyncHandler(async (_req, res) => {
/*
* The health endpoint checks the database on purpose instead of only proving
* that Express can answer HTTP. In this project, the server is not useful if
* MariaDB is unavailable, so health must include that dependency.
*/
await query('SELECT 1 AS ok');
res.json({
+30 -1
View File
@@ -2,26 +2,55 @@ import { Router } from 'express';
import { getLookup, listLookups } from '../services/lookupService.js';
import { asyncHandler } from '../utils/asyncHandler.js';
import { validateParam } from '../middleware/validateParams.js';
import { lookupCache } from '../services/cacheService.js';
const router = Router();
router.get(
'/',
asyncHandler(async (_req, res) => {
/*
* The bulk lookup endpoint is convenient for client startup because dropdown
* lists are small and reused across many dynamic fields. Fetching them in one
* call keeps the frontend startup sequence short.
*/
const cached = lookupCache.get('all-lookups');
if (cached) {
return res.json(cached);
}
const lookups = await listLookups();
res.json({ items: lookups });
const payload = { items: lookups };
lookupCache.set('all-lookups', payload);
return res.json(payload);
})
);
router.get(
'/:lookupCode',
validateParam('lookupCode'),
asyncHandler(async (req, res) => {
/*
* The single-lookup endpoint is still useful for debugging and for possible
* future optimization if the number of lookup sets grows and startup payloads
* need to become more selective.
*/
const cacheKey = `lookup-${req.params.lookupCode}`;
const cached = lookupCache.get(cacheKey);
if (cached) {
return res.json(cached);
}
const lookup = await getLookup(req.params.lookupCode);
if (!lookup) {
return res.status(404).json({ message: 'Lookup not found.' });
}
lookupCache.set(cacheKey, lookup);
return res.json(lookup);
})
);
+75
View File
@@ -0,0 +1,75 @@
import { Router } from 'express';
import { getReport, listReports, submitReport } from '../services/reportService.js';
import { logAuditEvent } from '../services/auditService.js';
import { asyncHandler } from '../utils/asyncHandler.js';
import { validateParam } from '../middleware/validateParams.js';
const router = Router();
/*
* Report submission accepts the full local report payload (answers, template
* binding, status) and stores it server-side. This bridges the offline-first
* client workflow with centralized storage for review and archival.
*/
router.get(
'/',
asyncHandler(async (req, res) => {
const reports = await listReports({
status: req.query.status || undefined,
templateCode: req.query.templateCode || undefined,
limit: Math.min(Number(req.query.limit) || 100, 500),
offset: Math.max(Number(req.query.offset) || 0, 0)
});
res.json({ items: reports });
})
);
router.get(
'/:reportId',
validateParam('reportId'),
asyncHandler(async (req, res) => {
const report = await getReport(req.params.reportId);
if (!report) {
return res.status(404).json({ message: 'Report not found.' });
}
return res.json(report);
})
);
router.post(
'/',
asyncHandler(async (req, res) => {
const body = req.body;
if (!body?.id || !body?.reportNumber || !body?.templateCode || !body?.templateVersion || !body?.answers) {
return res.status(400).json({
message: 'id, reportNumber, templateCode, templateVersion, and answers are required.'
});
}
const report = await submitReport({
id: String(body.id).trim(),
reportNumber: String(body.reportNumber).trim(),
templateCode: String(body.templateCode).trim(),
templateVersion: Number(body.templateVersion),
status: body.status || 'exported',
answers: body.answers
});
await logAuditEvent({
entityType: 'report',
entityCode: report.id,
action: 'submit',
newValue: { reportNumber: report.reportNumber, templateCode: report.templateCode }
});
return res.status(201).json(report);
})
);
export default router;
+99 -4
View File
@@ -2,37 +2,100 @@ import { Router } from 'express';
import {
getActiveTemplate,
getAllActiveTemplates,
getTemplateVersion,
listTemplates
listTemplates,
listTemplateVersions,
publishTemplateVersion
} from '../services/templateService.js';
import { logAuditEvent } from '../services/auditService.js';
import { asyncHandler } from '../utils/asyncHandler.js';
import { validateParam, validateNumericParam } from '../middleware/validateParams.js';
import { templateCache } from '../services/cacheService.js';
const router = Router();
router.get(
'/',
asyncHandler(async (_req, res) => {
asyncHandler(async (req, res) => {
/*
* When ?include=definitions is set the response embeds the full JSON
* definition for every active template. This eliminates the N+1 round-trip
* the old client performed (list → fetch each) and makes initial sync a
* single request. Without the flag the response stays lightweight.
*/
const includeDefinitions = req.query.include === 'definitions';
const cacheKey = `templates-list-${includeDefinitions}`;
const cached = templateCache.get(cacheKey);
if (cached) {
return res.json(cached);
}
if (includeDefinitions) {
const templates = await getAllActiveTemplates();
const payload = { items: templates };
templateCache.set(cacheKey, payload);
return res.json(payload);
}
const templates = await listTemplates();
res.json({ items: templates });
const payload = { items: templates };
templateCache.set(cacheKey, payload);
return res.json(payload);
})
);
router.get(
'/:templateCode',
validateParam('templateCode'),
asyncHandler(async (req, res) => {
/*
* New reports always use the latest active template, so the primary route is
* optimized for that case. Older versions remain accessible through the
* versioned route so existing drafts can stay bound to the original schema.
*/
const cacheKey = `template-active-${req.params.templateCode}`;
const cached = templateCache.get(cacheKey);
if (cached) {
return res.json(cached);
}
const template = await getActiveTemplate(req.params.templateCode);
if (!template) {
return res.status(404).json({ message: 'Template not found.' });
}
templateCache.set(cacheKey, template);
return res.json(template);
})
);
router.get(
'/:templateCode/versions/:versionNumber',
'/:templateCode/versions',
validateParam('templateCode'),
asyncHandler(async (req, res) => {
/*
* Version listing lets the admin workspace display a template's publication
* history and choose which version to activate or review.
*/
const versions = await listTemplateVersions(req.params.templateCode);
return res.json({ items: versions });
})
);
router.get(
'/:templateCode/versions/:versionNumber',
validateParam('templateCode'),
validateNumericParam('versionNumber'),
asyncHandler(async (req, res) => {
/*
* Version-specific access is what allows the frontend to reopen old drafts
* safely even after templates evolve. Without this route, cached reports
* would eventually drift away from the structure they were created against.
*/
const template = await getTemplateVersion(
req.params.templateCode,
req.params.versionNumber
@@ -46,4 +109,36 @@ router.get(
})
);
router.put(
'/:templateCode/versions/:versionNumber/publish',
validateParam('templateCode'),
validateNumericParam('versionNumber'),
asyncHandler(async (req, res) => {
/*
* Publishing a version marks it active and retires the previously active
* version for the same template. This lets the admin promote a draft version
* to production. Existing reports keep their bound version unchanged.
*/
const result = await publishTemplateVersion(
req.params.templateCode,
Number(req.params.versionNumber)
);
if (!result) {
return res.status(404).json({ message: 'Template version not found.' });
}
templateCache.clear();
await logAuditEvent({
entityType: 'template_version',
entityCode: `${req.params.templateCode}::v${req.params.versionNumber}`,
action: 'publish',
newValue: { templateCode: req.params.templateCode, version: Number(req.params.versionNumber) }
});
return res.json(result);
})
);
export default router;
+14
View File
@@ -3,6 +3,11 @@ import { env } from './config/env.js';
import { closePool, query } from './db/pool.js';
async function startServer() {
/*
* Fail fast on startup if the database is not reachable. For this project that
* is preferable to serving the frontend with a broken API because templates,
* lookups, and administrator configuration all depend on MariaDB.
*/
await query('SELECT 1 AS ok');
const server = app.listen(env.port, () => {
@@ -10,6 +15,11 @@ async function startServer() {
});
async function shutdown(signal) {
/*
* Graceful shutdown matters even in a PoC because Docker restarts and local
* stop/start cycles are common during development. Closing the HTTP server
* first and then the connection pool avoids abruptly dropping active work.
*/
console.log(`Received ${signal}, shutting down...`);
server.close(async () => {
await closePool();
@@ -22,6 +32,10 @@ async function startServer() {
}
startServer().catch(async (error) => {
/*
* If startup fails after the pool has been created, explicitly end the pool so
* the process does not linger with open handles and confusing partial state.
*/
console.error('Failed to start server');
console.error(error);
await closePool();
+75
View File
@@ -0,0 +1,75 @@
import { query } from '../db/pool.js';
/*
* The audit service records every administrative mutation so the team can trace
* when configuration changed and what the previous value was. Each row captures
* the entity type (e.g. "image_rules"), the entity identifier, the action name,
* and JSON snapshots of the old and new values.
*/
export async function logAuditEvent({ entityType, entityCode, action, oldValue = null, newValue = null }) {
await query(
`
INSERT INTO audit_log (entity_type, entity_code, action, old_value_json, new_value_json)
VALUES (?, ?, ?, ?, ?)
`,
[
entityType,
entityCode,
action,
oldValue ? JSON.stringify(oldValue) : null,
newValue ? JSON.stringify(newValue) : null
]
);
}
export async function getAuditLog({ entityType, entityCode, limit = 50 } = {}) {
let sql = 'SELECT id, entity_type, entity_code, action, old_value_json, new_value_json, created_at FROM audit_log';
const params = [];
const clauses = [];
if (entityType) {
clauses.push('entity_type = ?');
params.push(entityType);
}
if (entityCode) {
clauses.push('entity_code = ?');
params.push(entityCode);
}
if (clauses.length) {
sql += ` WHERE ${clauses.join(' AND ')}`;
}
sql += ' ORDER BY id DESC LIMIT ?';
params.push(limit);
const rows = await query(sql, params);
return rows.map((row) => ({
id: row.id,
entityType: row.entity_type,
entityCode: row.entity_code,
action: row.action,
oldValue: safeParseJson(row.old_value_json),
newValue: safeParseJson(row.new_value_json),
createdAt: row.created_at
}));
}
function safeParseJson(value) {
if (value == null) {
return null;
}
if (typeof value === 'object') {
return value;
}
try {
return JSON.parse(value);
} catch {
return null;
}
}
+70
View File
@@ -0,0 +1,70 @@
/*
* Simple in-memory LRU cache for read-heavy data such as templates and lookups.
* Each cache entry tracks its last-access timestamp. When the cache exceeds the
* configured maximum size the least-recently-used entry is evicted automatically.
*
* This avoids hitting MariaDB on every request for data that changes rarely while
* keeping the implementation dependency-free.
*/
export function createCache({ maxEntries = 100, ttlMs = 5 * 60 * 1000 } = {}) {
const store = new Map();
function get(key) {
const entry = store.get(key);
if (!entry) {
return undefined;
}
if (Date.now() - entry.createdAt > ttlMs) {
store.delete(key);
return undefined;
}
entry.lastAccess = Date.now();
return entry.value;
}
function set(key, value) {
if (store.size >= maxEntries) {
evictLru();
}
store.set(key, { value, createdAt: Date.now(), lastAccess: Date.now() });
}
function invalidate(key) {
store.delete(key);
}
function clear() {
store.clear();
}
function evictLru() {
let oldestKey = null;
let oldestAccess = Infinity;
for (const [key, entry] of store) {
if (entry.lastAccess < oldestAccess) {
oldestAccess = entry.lastAccess;
oldestKey = key;
}
}
if (oldestKey !== null) {
store.delete(oldestKey);
}
}
return { get, set, invalidate, clear };
}
/*
* Shared cache instances. Templates and lookups change rarely enough that a
* five-minute TTL is practical during normal operations. The admin invalidates
* the relevant cache key on write so changes appear immediately.
*/
export const templateCache = createCache({ maxEntries: 50, ttlMs: 5 * 60 * 1000 });
export const lookupCache = createCache({ maxEntries: 50, ttlMs: 5 * 60 * 1000 });
export const configCache = createCache({ maxEntries: 20, ttlMs: 2 * 60 * 1000 });
+54
View File
@@ -1,6 +1,12 @@
import { query } from '../db/pool.js';
import { parseJsonColumn } from '../utils/json.js';
/*
* Phase 1 keeps exactly one active image rule set. The frontend asks only for
* the active rule because that matches the current business need: operators use
* the latest centrally managed policy, while drafts themselves do not yet store
* an immutable copy of the rule configuration.
*/
export async function getImageRules() {
const rows = await query(
`
@@ -31,6 +37,49 @@ export async function getImageRules() {
};
}
export async function updateImageRules(nextImageRules) {
const currentRules = await getImageRules();
if (!currentRules) {
return null;
}
/*
* The PoC updates the currently active rule in place instead of creating a new
* version row. That keeps the administrator flow small and easy to reason
* about. If later phases need audit history, this is the point where versioned
* writes or soft-retired rows should be introduced.
*/
await query(
`
UPDATE image_rules
SET
name = ?,
allowed_mime_types_json = ?,
max_file_size_bytes = ?,
max_width_px = ?,
max_height_px = ?,
jpeg_quality = ?,
oversize_behavior = ?,
max_attachments_per_field = ?
WHERE code = ?
`,
[
nextImageRules.name,
JSON.stringify(nextImageRules.allowedMimeTypes),
nextImageRules.maxFileSizeBytes,
nextImageRules.maxWidthPx,
nextImageRules.maxHeightPx,
nextImageRules.jpegQuality,
nextImageRules.oversizeBehavior,
nextImageRules.maxAttachmentsPerField,
currentRules.code
]
);
return getImageRules();
}
export async function getExportProfile() {
const rows = await query(
`
@@ -52,6 +101,11 @@ export async function getExportProfile() {
}
export async function getAppConfig() {
/*
* Config values are stored as JSON so the frontend can receive structured data
* without a separate table for every small setting. The helper converts JSON
* strings into usable objects and arrays before returning them.
*/
const rows = await query(
`
SELECT
+14
View File
@@ -1,6 +1,11 @@
import { query } from '../db/pool.js';
function groupLookups(rows) {
/*
* SQL returns one row per lookup value, but the frontend wants a grouped shape
* where each lookup code owns an array of options. Building that structure here
* keeps the API contract friendly for dynamic form rendering.
*/
const lookups = new Map();
for (const row of rows) {
@@ -26,6 +31,10 @@ function groupLookups(rows) {
}
export async function listLookups() {
/*
* Active lookup values are sorted in SQL so the client receives them in display
* order without additional sorting logic in the browser.
*/
const rows = await query(
`
SELECT
@@ -48,6 +57,11 @@ export async function listLookups() {
}
export async function getLookup(lookupCode) {
/*
* The single-lookup query reuses the same grouping logic as the bulk endpoint,
* which keeps the returned shape consistent regardless of how the frontend or a
* debugging tool chooses to retrieve lookup data.
*/
const rows = await query(
`
SELECT
+108
View File
@@ -0,0 +1,108 @@
import { query } from '../db/pool.js';
import { parseJsonColumn } from '../utils/json.js';
/*
* The report service handles server-side storage of submitted reports. In
* phase 1, reports are created locally in the browser and only uploaded when
* the operator explicitly submits. This keeps the offline-first workflow intact
* while giving the backend a durable copy for review, export, or archival.
*/
export async function submitReport(report) {
await query(
`
INSERT INTO reports (report_uuid, report_number, template_code, template_version, status, answers_json, submitted_at)
VALUES (?, ?, ?, ?, ?, ?, NOW())
ON DUPLICATE KEY UPDATE
status = VALUES(status),
answers_json = VALUES(answers_json),
submitted_at = VALUES(submitted_at),
updated_at = NOW()
`,
[
report.id,
report.reportNumber,
report.templateCode,
report.templateVersion,
report.status,
JSON.stringify(report.answers)
]
);
return getReport(report.id);
}
export async function getReport(reportUuid) {
const rows = await query(
`
SELECT
report_uuid AS reportUuid,
report_number AS reportNumber,
template_code AS templateCode,
template_version AS templateVersion,
status,
answers_json AS answersJson,
submitted_at AS submittedAt,
created_at AS createdAt,
updated_at AS updatedAt
FROM reports
WHERE report_uuid = ?
LIMIT 1
`,
[reportUuid]
);
return rows.length ? mapReportRow(rows[0]) : null;
}
export async function listReports({ status, templateCode, limit = 100, offset = 0 } = {}) {
let sql = `
SELECT
report_uuid AS reportUuid,
report_number AS reportNumber,
template_code AS templateCode,
template_version AS templateVersion,
status,
answers_json AS answersJson,
submitted_at AS submittedAt,
created_at AS createdAt,
updated_at AS updatedAt
FROM reports
`;
const params = [];
const clauses = [];
if (status) {
clauses.push('status = ?');
params.push(status);
}
if (templateCode) {
clauses.push('template_code = ?');
params.push(templateCode);
}
if (clauses.length) {
sql += ` WHERE ${clauses.join(' AND ')}`;
}
sql += ' ORDER BY updated_at DESC LIMIT ? OFFSET ?';
params.push(limit, offset);
const rows = await query(sql, params);
return rows.map(mapReportRow);
}
function mapReportRow(row) {
return {
id: row.reportUuid,
reportNumber: row.reportNumber,
templateCode: row.templateCode,
templateVersion: row.templateVersion,
status: row.status,
answers: parseJsonColumn(row.answersJson, {}),
submittedAt: row.submittedAt,
createdAt: row.createdAt,
updatedAt: row.updatedAt
};
}
+118
View File
@@ -2,6 +2,11 @@ import { query } from '../db/pool.js';
import { parseJsonColumn } from '../utils/json.js';
function mapTemplateRow(row) {
/*
* Template definitions are stored as JSON in MariaDB, but the frontend expects
* them as native objects. The mapper centralizes that translation and keeps the
* route handlers free from storage-specific details.
*/
return {
code: row.code,
name: row.name,
@@ -14,6 +19,11 @@ function mapTemplateRow(row) {
}
export async function listTemplates() {
/*
* Only active versions are listed for new report creation. Retired or draft
* versions may still exist in the database, but they should not appear in the
* main template picker used by operators.
*/
const rows = await query(
`
SELECT
@@ -40,7 +50,40 @@ export async function listTemplates() {
}));
}
export async function getAllActiveTemplates() {
/*
* Batch endpoint: returns every active template with its full JSON definition
* in a single query. This replaces the N+1 pattern where the client listed
* templates then fetched each definition individually — cutting initial sync
* from N+1 round-trips to one.
*/
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'
ORDER BY t.name ASC
`
);
return rows.map(mapTemplateRow);
}
export async function getActiveTemplate(templateCode) {
/*
* This query returns the single currently active version for a given template
* code. That matches the business rule that new drafts should always start from
* the newest active template definition.
*/
const rows = await query(
`
SELECT
@@ -65,6 +108,11 @@ export async function getActiveTemplate(templateCode) {
}
export async function getTemplateVersion(templateCode, versionNumber) {
/*
* Version-specific reads are intentionally separate from active-template reads
* so draft reopening can be explicit and reliable, even if the active version
* changes later.
*/
const rows = await query(
`
SELECT
@@ -87,3 +135,73 @@ export async function getTemplateVersion(templateCode, versionNumber) {
return rows.length ? mapTemplateRow(rows[0]) : null;
}
export async function listTemplateVersions(templateCode) {
/*
* Returns all versions of a template so the admin workspace can show the full
* version history and allow publishing a different version.
*/
const rows = await query(
`
SELECT
t.code,
t.name,
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
WHERE t.code = ?
ORDER BY tv.version_number DESC
`,
[templateCode]
);
return rows.map((row) => ({
code: row.code,
name: row.name,
version: row.versionNumber,
status: row.status,
publishedAt: row.publishedAt
}));
}
export async function publishTemplateVersion(templateCode, versionNumber) {
/*
* Publishing retires the currently active version and activates the requested
* one. Both updates run sequentially. In production this would be wrapped in a
* database transaction; the PoC trades strict atomicity for simplicity.
*/
const templateRows = await query(
'SELECT id FROM templates WHERE code = ?',
[templateCode]
);
if (!templateRows.length) {
return null;
}
const templateId = templateRows[0].id;
const versionRows = await query(
'SELECT id FROM template_versions WHERE template_id = ? AND version_number = ?',
[templateId, versionNumber]
);
if (!versionRows.length) {
return null;
}
await query(
"UPDATE template_versions SET status = 'retired' WHERE template_id = ? AND status = 'active'",
[templateId]
);
await query(
"UPDATE template_versions SET status = 'active', published_at = NOW() WHERE id = ?",
[versionRows[0].id]
);
return getActiveTemplate(templateCode);
}