commit 08dc415bfd24e28c1cc08dbaf7afb6e6e5eb2841 Author: Paul Trowbridge Date: Wed Apr 1 07:59:05 2026 -0400 Initial commit — pivot forecast application Node.js/Express + PostgreSQL forecasting app with AG Grid Enterprise pivot UI. Supports baseline, scale, recode, clone operations on configurable source tables. Co-Authored-By: Claude Sonnet 4.6 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0e6a3cc --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +DB_HOST=localhost +DB_PORT=5432 +DB_NAME=your_database +DB_USER=your_user +DB_PASSWORD=your_password +PORT=3010 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..713d500 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +.env diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..6d0ca93 --- /dev/null +++ b/install.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +set -e + +echo "" +echo "========================================" +echo " Pivot Forecast — Install" +echo "========================================" +echo "" + +# ── DB connection ───────────────────────────────────────────── +read -p "DB host [192.168.1.110]: " DB_HOST +DB_HOST=${DB_HOST:-192.168.1.110} + +read -p "DB port [5432]: " DB_PORT +DB_PORT=${DB_PORT:-5432} + +read -p "DB name [ubm]: " DB_NAME +DB_NAME=${DB_NAME:-ubm} + +read -p "DB user [ptrowbridge]: " DB_USER +DB_USER=${DB_USER:-ptrowbridge} + +read -s -p "DB password: " DB_PASSWORD +echo "" + +read -p "App port [3030]: " PORT +PORT=${PORT:-3030} + +# ── Write .env ──────────────────────────────────────────────── +cat > .env < c.role === 'dimension') + .sort((a, b) => (a.opos || 0) - (b.opos || 0)) + .map(c => c.cname); + + const valueCol = colMeta.find(c => c.role === 'value')?.cname; + const unitsCol = colMeta.find(c => c.role === 'units')?.cname; + const dateCol = colMeta.find(c => c.role === 'date')?.cname; + + if (!valueCol) throw new Error('No value column defined in col_meta'); + if (!unitsCol) throw new Error('No units column defined in col_meta'); + if (!dateCol) throw new Error('No date column defined in col_meta'); + if (dims.length === 0) throw new Error('No dimension columns defined in col_meta'); + + const srcTable = `${source.schema}.${source.tname}`; + // exclude 'id' — forecast table has its own bigserial id primary key + const dataCols = [...dims, dateCol, valueCol, unitsCol].filter(c => c !== 'id'); + const effectiveValue = dataCols.includes(valueCol) ? valueCol : null; + const effectiveUnits = dataCols.includes(unitsCol) ? unitsCol : null; + const insertCols = [...dataCols.map(q), 'iter', 'logid', 'pf_user', 'created_at'].join(', '); + const selectData = dataCols.map(q).join(', '); + const dimsJoined = dims.map(q).join(', '); + + return { + get_data: buildGetData(), + baseline: buildBaseline(), + reference: buildReference(), + scale: buildScale(), + recode: buildRecode(), + clone: buildClone(), + undo: buildUndo() + }; + + function buildGetData() { + return `SELECT * FROM {{fc_table}}`; + } + + function buildBaseline() { + return ` +WITH +ilog AS ( + INSERT INTO pf.log (version_id, pf_user, operation, slice, params, note) + VALUES ({{version_id}}, '{{pf_user}}', 'baseline', NULL, '{{params}}'::jsonb, '{{note}}') + RETURNING id +) +,del AS ( + DELETE FROM {{fc_table}} WHERE iter = 'baseline' +) +,ins AS ( + INSERT INTO {{fc_table}} (${insertCols}) + SELECT ${selectData}, 'baseline', (SELECT id FROM ilog), '{{pf_user}}', now() + FROM ${srcTable} + WHERE ${q(dateCol)} BETWEEN '{{date_from}}' AND '{{date_to}}' + RETURNING * +) +SELECT count(*) AS rows_affected FROM ins`.trim(); + } + + function buildReference() { + return ` +WITH +ilog AS ( + INSERT INTO pf.log (version_id, pf_user, operation, slice, params, note) + VALUES ({{version_id}}, '{{pf_user}}', 'reference', NULL, '{{params}}'::jsonb, '{{note}}') + RETURNING id +) +,ins AS ( + INSERT INTO {{fc_table}} (${insertCols}) + SELECT ${selectData}, 'reference', (SELECT id FROM ilog), '{{pf_user}}', now() + FROM ${srcTable} + WHERE ${q(dateCol)} BETWEEN '{{date_from}}' AND '{{date_to}}' + RETURNING * +) +SELECT count(*) AS rows_affected FROM ins`.trim(); + } + + function buildScale() { + const vSel = effectiveValue + ? `round((${q(effectiveValue)} / NULLIF(total_value, 0)) * {{value_incr}}, 2)` + : `0`; + const uSel = effectiveUnits + ? `round((${q(effectiveUnits)} / NULLIF(total_units, 0)) * {{units_incr}}, 5)` + : `0`; + const baseSelectParts = [ + ...dimsJoined ? [dimsJoined] : [], + q(dateCol), + effectiveValue ? q(effectiveValue) : null, + effectiveUnits ? q(effectiveUnits) : null, + effectiveValue ? `sum(${q(effectiveValue)}) OVER () AS total_value` : null, + effectiveUnits ? `sum(${q(effectiveUnits)}) OVER () AS total_units` : null + ].filter(Boolean).join(',\n '); + return ` +WITH +ilog AS ( + INSERT INTO pf.log (version_id, pf_user, operation, slice, params, note) + VALUES ({{version_id}}, '{{pf_user}}', 'scale', '{{slice}}'::jsonb, '{{params}}'::jsonb, '{{note}}') + RETURNING id +) +,base AS ( + SELECT + ${baseSelectParts} + FROM {{fc_table}} + WHERE {{where_clause}} + {{exclude_clause}} +) +,ins AS ( + INSERT INTO {{fc_table}} (${insertCols}) + SELECT + ${[dimsJoined, q(dateCol), ...(effectiveValue ? [vSel] : []), ...(effectiveUnits ? [uSel] : [])].join(',\n ')}, + 'scale', (SELECT id FROM ilog), '{{pf_user}}', now() + FROM base + RETURNING * +) +SELECT count(*) AS rows_affected FROM ins`.trim(); + } + + function buildRecode() { + return ` +WITH +ilog AS ( + INSERT INTO pf.log (version_id, pf_user, operation, slice, params, note) + VALUES ({{version_id}}, '{{pf_user}}', 'recode', '{{slice}}'::jsonb, '{{params}}'::jsonb, '{{note}}') + RETURNING id +) +,src AS ( + SELECT ${selectData} + FROM {{fc_table}} + WHERE {{where_clause}} + {{exclude_clause}} +) +,neg AS ( + INSERT INTO {{fc_table}} (${insertCols}) + SELECT ${dimsJoined}, ${q(dateCol)}, ${effectiveValue ? `-${q(effectiveValue)}` : '0'}, ${effectiveUnits ? `-${q(effectiveUnits)}` : '0'}, + 'recode', (SELECT id FROM ilog), '{{pf_user}}', now() + FROM src + RETURNING id +) +,ins AS ( + INSERT INTO {{fc_table}} (${insertCols}) + SELECT {{set_clause}}, ${q(dateCol)}, ${effectiveValue ? q(effectiveValue) : '0'}, ${effectiveUnits ? q(effectiveUnits) : '0'}, + 'recode', (SELECT id FROM ilog), '{{pf_user}}', now() + FROM src + RETURNING id +) +SELECT (SELECT count(*) FROM neg) + (SELECT count(*) FROM ins) AS rows_affected`.trim(); + } + + function buildClone() { + return ` +WITH +ilog AS ( + INSERT INTO pf.log (version_id, pf_user, operation, slice, params, note) + VALUES ({{version_id}}, '{{pf_user}}', 'clone', '{{slice}}'::jsonb, '{{params}}'::jsonb, '{{note}}') + RETURNING id +) +,ins AS ( + INSERT INTO {{fc_table}} (${insertCols}) + SELECT + {{set_clause}}, + ${q(dateCol)}, + ${effectiveValue ? `round(${q(effectiveValue)} * {{scale_factor}}, 2)` : '0'}, + ${effectiveUnits ? `round(${q(effectiveUnits)} * {{scale_factor}}, 5)` : '0'}, + 'clone', (SELECT id FROM ilog), '{{pf_user}}', now() + FROM {{fc_table}} + WHERE {{where_clause}} + {{exclude_clause}} + RETURNING * +) +SELECT count(*) AS rows_affected FROM ins`.trim(); + } + + function buildUndo() { + // undo is executed as two separate queries in the route handler + // (delete from fc_table first, then delete from pf.log) to avoid + // FK constraint ordering issues within a single CTE statement. + // This entry is a placeholder — the undo route uses it as a template reference. + return ` +-- step 1 (run first): +DELETE FROM {{fc_table}} WHERE logid = {{logid}}; +-- step 2 (run after step 1): +DELETE FROM pf.log WHERE id = {{logid}};`.trim(); + } +} + +// substitute {{token}} placeholders in a SQL string +function applyTokens(sql, tokens) { + let result = sql; + for (const [key, value] of Object.entries(tokens)) { + result = result.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), value ?? ''); + } + return result; +} + +// build a SQL WHERE clause string from a slice object +// validates all keys against the allowed dimension column list +function buildWhere(slice, dimCols) { + if (!slice || Object.keys(slice).length === 0) return 'TRUE'; + + const allowed = new Set(dimCols); + const parts = []; + + for (const [col, val] of Object.entries(slice)) { + if (!allowed.has(col)) { + throw new Error(`"${col}" is not a dimension column`); + } + if (Array.isArray(val)) { + const escaped = val.map(v => esc(v)); + parts.push(`"${col}" IN ('${escaped.join("', '")}')`); + } else { + parts.push(`"${col}" = '${esc(val)}'`); + } + } + + return parts.join('\nAND '); +} + +// build AND iter NOT IN (...) from a version's exclude_iters array +function buildExcludeClause(excludeIters) { + if (!excludeIters || excludeIters.length === 0) return ''; + const list = excludeIters.map(i => `'${esc(i)}'`).join(', '); + return `AND iter NOT IN (${list})`; +} + +// build the dimension columns portion of a SELECT for recode/clone +// replaces named dimensions with literal values, passes others through unchanged +function buildSetClause(dimCols, setObj) { + return dimCols.map(col => { + if (setObj && setObj[col] !== undefined) { + return `'${esc(setObj[col])}' AS "${col}"`; + } + return `"${col}"`; + }).join(', '); +} + +// escape a value for safe SQL string substitution +function esc(val) { + if (val === null || val === undefined) return ''; + return String(val).replace(/'/g, "''"); +} + +module.exports = { generateSQL, applyTokens, buildWhere, buildExcludeClause, buildSetClause, esc }; diff --git a/lib/utils.js b/lib/utils.js new file mode 100644 index 0000000..c1245e8 --- /dev/null +++ b/lib/utils.js @@ -0,0 +1,39 @@ +// derive forecast table name from source tname + version id +function fcTable(tname, versionId) { + return `pf.fc_${tname}_${versionId}`; +} + +// map information_schema data_type to a clean postgres column type +function mapType(dataType, numericPrecision, numericScale) { + switch (dataType) { + case 'character varying': + case 'character': + case 'text': + return 'text'; + case 'smallint': + case 'integer': + return 'integer'; + case 'bigint': + return 'bigint'; + case 'numeric': + case 'decimal': + return (numericPrecision) + ? `numeric(${numericPrecision}, ${numericScale || 0})` + : 'numeric'; + case 'real': + case 'double precision': + return 'numeric'; + case 'date': + return 'date'; + case 'timestamp without time zone': + return 'timestamp'; + case 'timestamp with time zone': + return 'timestamptz'; + case 'boolean': + return 'boolean'; + default: + return 'text'; + } +} + +module.exports = { fcTable, mapType }; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..15b6290 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1392 @@ +{ + "name": "pf_app", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pf_app", + "version": "1.0.0", + "dependencies": { + "cors": "^2.8.5", + "dotenv": "^16.0.0", + "express": "^4.18.2", + "pg": "^8.11.3" + }, + "devDependencies": { + "nodemon": "^3.0.0" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nodemon": { + "version": "3.1.14", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", + "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^10.2.1", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/nodemon/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", + "license": "MIT", + "peer": true, + "dependencies": { + "pg-connection-string": "^2.12.0", + "pg-pool": "^3.13.0", + "pg-protocol": "^1.13.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", + "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", + "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", + "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..9267ec7 --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "pf_app", + "version": "1.0.0", + "description": "Pivot Forecast Application", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "nodemon server.js" + }, + "dependencies": { + "cors": "^2.8.5", + "dotenv": "^16.0.0", + "express": "^4.18.2", + "pg": "^8.11.3" + }, + "devDependencies": { + "nodemon": "^3.0.0" + } +} diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..76d7dcf --- /dev/null +++ b/public/app.js @@ -0,0 +1,904 @@ +/* ============================================================ + STATE + ============================================================ */ +const state = { + view: 'sources', + source: null, // selected pf.source row + version: null, // selected pf.version row + selectedVersionId: null, // in versions grid + colMeta: [], // col_meta for selected source + slice: {}, // current pivot selection + loadDataOp: null, // 'baseline' | 'reference' + previewSchema: null, + previewTname: null, + grids: { + tables: null, + sources: null, + colMeta: null, + versions: null, + pivot: null, + log: null + } +}; + +/* ============================================================ + API + ============================================================ */ +async function api(method, path, body) { + const opts = { method, headers: { 'Content-Type': 'application/json' } }; + if (body !== undefined) opts.body = JSON.stringify(body); + const res = await fetch('/api' + path, opts); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); + return data; +} + +/* ============================================================ + STATUS BAR + ============================================================ */ +let statusTimer = null; +function showStatus(msg, type = 'info') { + const bar = document.getElementById('status-bar'); + bar.textContent = msg; + bar.className = `status-${type}`; + bar.classList.remove('hidden'); + clearTimeout(statusTimer); + if (type !== 'error') statusTimer = setTimeout(() => bar.classList.add('hidden'), 4000); +} + +/* ============================================================ + NAVIGATION + ============================================================ */ +function switchView(name) { + if ((name === 'forecast' || name === 'log') && !state.version) { + showStatus('Select a version first', 'error'); + return; + } + document.querySelectorAll('.view').forEach(el => { + el.classList.add('hidden'); + el.classList.remove('active'); + }); + const target = document.getElementById(`view-${name}`); + target.classList.remove('hidden'); + target.classList.add('active'); + document.querySelectorAll('.nav-links li').forEach(li => + li.classList.toggle('active', li.dataset.view === name) + ); + state.view = name; + if (name === 'versions') renderVersions(); + if (name === 'forecast') loadForecastData(); + if (name === 'log') loadLogData(); +} + +function setSource(source) { + state.source = source; + const el = document.getElementById('ctx-source'); + if (source) { + el.classList.remove('hidden'); + document.getElementById('ctx-source-name').textContent = `${source.schema}.${source.tname}`; + } else { + el.classList.add('hidden'); + } +} + +function setVersion(version) { + state.version = version; + const el = document.getElementById('ctx-version'); + if (version) { + el.classList.remove('hidden'); + document.getElementById('ctx-version-name').textContent = version.name; + const badge = document.getElementById('ctx-version-status'); + badge.textContent = version.status; + badge.className = `status-badge ${version.status}`; + } else { + el.classList.add('hidden'); + } +} + +function getPfUser() { + return document.getElementById('input-pf-user').value.trim() || 'unknown'; +} + +/* ============================================================ + SOURCES VIEW — table browser + ============================================================ */ +async function initSourcesView() { + const tables = await api('GET', '/tables'); + renderTablesGrid(tables); + const sources = await api('GET', '/sources'); + renderSourcesGrid(sources); +} + +function renderTablesGrid(tables) { + const el = document.getElementById('tables-grid'); + if (state.grids.tables) { state.grids.tables.setGridOption('rowData', tables); return; } + + state.grids.tables = agGrid.createGrid(el, { + columnDefs: [ + { field: 'schema', headerName: 'Schema', width: 90 }, + { field: 'tname', headerName: 'Table', flex: 1 }, + { field: 'row_estimate', headerName: 'Rows', width: 80, + valueFormatter: p => p.value != null ? Number(p.value).toLocaleString() : '—' } + ], + rowData: tables, + rowSelection: 'single', + onRowClicked: onTableRowClicked, + onRowDoubleClicked: e => showTablePreview(e.data.schema, e.data.tname), + defaultColDef: { resizable: true, sortable: true }, + headerHeight: 32, rowHeight: 28 + }); +} + +function onTableRowClicked(e) { + const { schema, tname } = e.data; + state.previewSchema = schema; + state.previewTname = tname; + document.getElementById('btn-register').classList.remove('hidden'); +} + +async function showTablePreview(schema, tname) { + try { + const data = await api('GET', `/tables/${schema}/${tname}/preview`); + const title = document.getElementById('modal-title'); + const body = document.getElementById('modal-body'); + title.textContent = `${schema}.${tname}`; + + // columns table + let colHtml = `

Columns

+ `; + data.columns.forEach(c => { + colHtml += ``; + }); + colHtml += '
ColumnTypeNullable
${c.column_name}${c.data_type}${c.is_nullable}
'; + + // sample rows + let rowHtml = ''; + if (data.rows.length > 0) { + const cols = Object.keys(data.rows[0]); + rowHtml = `

Sample rows

`; + cols.forEach(c => rowHtml += ``); + rowHtml += ''; + data.rows.forEach(row => { + rowHtml += ''; + cols.forEach(c => rowHtml += ``); + rowHtml += ''; + }); + rowHtml += '
${c}
${row[c] ?? ''}
'; + } + + body.innerHTML = colHtml + rowHtml; + state.previewSchema = schema; + state.previewTname = tname; + document.getElementById('modal-overlay').classList.remove('hidden'); + } catch (err) { + showStatus(err.message, 'error'); + } +} + +async function registerTable(schema, tname) { + try { + await api('POST', '/sources', { schema, tname, created_by: getPfUser() }); + showStatus(`Registered ${schema}.${tname}`, 'success'); + const sources = await api('GET', '/sources'); + renderSourcesGrid(sources); + document.getElementById('modal-overlay').classList.add('hidden'); + } catch (err) { + showStatus(err.message, 'error'); + } +} + +/* ============================================================ + SOURCES VIEW — registered sources + col_meta + ============================================================ */ +function renderSourcesGrid(sources) { + const el = document.getElementById('sources-list-grid'); + if (state.grids.sources) { state.grids.sources.setGridOption('rowData', sources); return; } + + state.grids.sources = agGrid.createGrid(el, { + columnDefs: [ + { field: 'schema', headerName: 'Schema', width: 90 }, + { field: 'tname', headerName: 'Table', flex: 1 }, + { field: 'label', headerName: 'Label', flex: 1 }, + { field: 'status', headerName: 'Status', width: 70 } + ], + rowData: sources, + rowSelection: 'single', + onRowClicked: e => selectSource(e.data), + defaultColDef: { resizable: true, sortable: true }, + headerHeight: 32, rowHeight: 28 + }); +} + +async function selectSource(source) { + setSource(source); + try { + state.colMeta = await api('GET', `/sources/${source.id}/cols`); + renderColMetaGrid(state.colMeta); + document.getElementById('sources-list-grid').classList.add('hidden'); + document.getElementById('col-meta-grid').classList.remove('hidden'); + document.getElementById('right-panel-title').textContent = `${source.schema}.${source.tname} — Columns`; + document.getElementById('btn-back-sources').classList.remove('hidden'); + document.getElementById('btn-save-cols').classList.remove('hidden'); + document.getElementById('btn-generate-sql').classList.remove('hidden'); + } catch (err) { + showStatus(err.message, 'error'); + } +} + +function backToSources() { + document.getElementById('sources-list-grid').classList.remove('hidden'); + document.getElementById('col-meta-grid').classList.add('hidden'); + document.getElementById('right-panel-title').textContent = 'Registered Sources'; + document.getElementById('btn-back-sources').classList.add('hidden'); + document.getElementById('btn-save-cols').classList.add('hidden'); + document.getElementById('btn-generate-sql').classList.add('hidden'); +} + +function renderColMetaGrid(colMeta) { + const el = document.getElementById('col-meta-grid'); + if (state.grids.colMeta) { state.grids.colMeta.setGridOption('rowData', colMeta); return; } + + state.grids.colMeta = agGrid.createGrid(el, { + columnDefs: [ + { field: 'opos', headerName: '#', width: 45, sortable: true }, + { field: 'cname', headerName: 'Column', flex: 1 }, + { + field: 'role', headerName: 'Role', width: 110, editable: true, + cellEditor: 'agSelectCellEditor', + cellEditorParams: { values: ['dimension', 'value', 'units', 'date', 'ignore'] }, + cellStyle: p => { + const colors = { dimension: '#eaf4fb', value: '#eafaf1', units: '#fef9e7', date: '#fdf2f8', ignore: '#f9f9f9' }; + return { background: colors[p.value] || '' }; + } + }, + { + field: 'is_key', headerName: 'Key', width: 55, editable: true, + cellRenderer: p => p.value ? '✓' : '', + cellEditor: 'agCheckboxCellEditor' + }, + { field: 'label', headerName: 'Label', flex: 1, editable: true, + cellEditorParams: { useFormatter: true } } + ], + rowData: colMeta, + defaultColDef: { resizable: true }, + headerHeight: 32, rowHeight: 28, + singleClickEdit: true, + stopEditingWhenCellsLoseFocus: true + }); +} + +async function saveColMeta() { + if (!state.source) return; + try { + const rows = []; + state.grids.colMeta.forEachNode(n => rows.push(n.data)); + state.colMeta = await api('PUT', `/sources/${state.source.id}/cols`, rows); + state.grids.colMeta.setGridOption('rowData', state.colMeta); + showStatus('Columns saved', 'success'); + } catch (err) { + showStatus(err.message, 'error'); + } +} + +async function generateSQL() { + if (!state.source) return; + try { + const result = await api('POST', `/sources/${state.source.id}/generate-sql`); + showStatus(`SQL generated: ${result.operations.join(', ')}`, 'success'); + } catch (err) { + showStatus(err.message, 'error'); + } +} + +/* ============================================================ + VERSIONS VIEW + ============================================================ */ +async function renderVersions() { + if (!state.source) { + document.getElementById('versions-source-label').textContent = 'No source selected — go to Sources first'; + return; + } + document.getElementById('versions-source-label').textContent = `${state.source.schema}.${state.source.tname}`; + try { + const versions = await api('GET', `/sources/${state.source.id}/versions`); + renderVersionsGrid(versions); + } catch (err) { + showStatus(err.message, 'error'); + } +} + +function renderVersionsGrid(versions) { + const el = document.getElementById('versions-grid'); + + const colDefs = [ + { field: 'id', headerName: 'ID', width: 55 }, + { field: 'name', headerName: 'Name', flex: 1 }, + { field: 'description', headerName: 'Desc', flex: 1 }, + { field: 'status', headerName: 'Status', width: 75, + cellStyle: p => ({ color: p.value === 'open' ? '#27ae60' : '#e74c3c', fontWeight: 600 }) }, + { field: 'created_by', headerName: 'Created by', width: 100 }, + { field: 'created_at', headerName: 'Created', width: 140, + valueFormatter: p => p.value ? new Date(p.value).toLocaleString() : '' } + ]; + + if (state.grids.versions) { + state.grids.versions.setGridOption('rowData', versions); + state.grids.versions.setGridOption('columnDefs', colDefs); + return; + } + + state.grids.versions = agGrid.createGrid(el, { + columnDefs: colDefs, + rowData: versions, + rowSelection: 'single', + onRowClicked: onVersionRowClicked, + defaultColDef: { resizable: true, sortable: true }, + headerHeight: 32, rowHeight: 28 + }); +} + +function onVersionRowClicked(e) { + const v = e.data; + state.selectedVersionId = v.id; + const panel = document.getElementById('version-actions'); + panel.classList.remove('hidden'); + document.getElementById('version-actions-label').textContent = v.name; + document.getElementById('vbtn-toggle').textContent = v.status === 'open' ? 'Close Version' : 'Reopen Version'; + document.getElementById('load-data-form').classList.add('hidden'); +} + +function showNewVersionForm() { + document.getElementById('new-version-form').classList.remove('hidden'); + document.getElementById('ver-name').focus(); +} + +async function createVersion() { + const name = document.getElementById('ver-name').value.trim(); + if (!name) { showStatus('Version name is required', 'error'); return; } + try { + await api('POST', `/sources/${state.source.id}/versions`, { + name, + description: document.getElementById('ver-desc').value.trim() || undefined, + created_by: getPfUser() + }); + document.getElementById('new-version-form').classList.add('hidden'); + document.getElementById('ver-name').value = ''; + document.getElementById('ver-desc').value = ''; + showStatus(`Version "${name}" created`, 'success'); + await renderVersions(); + } catch (err) { + showStatus(err.message, 'error'); + } +} + +function showLoadForm(op) { + state.loadDataOp = op; + document.getElementById('load-data-title').textContent = + op === 'baseline' ? 'Load Baseline' : 'Load Reference'; + document.getElementById('load-data-form').classList.remove('hidden'); + document.getElementById('load-date-from').focus(); +} + +async function submitLoadData() { + const date_from = document.getElementById('load-date-from').value; + const date_to = document.getElementById('load-date-to').value; + if (!date_from || !date_to) { showStatus('Both dates are required', 'error'); return; } + if (!state.selectedVersionId) { showStatus('No version selected', 'error'); return; } + + const body = { + date_from, date_to, + pf_user: getPfUser(), + note: document.getElementById('load-note').value.trim() || undefined + }; + + try { + showStatus(`Loading ${state.loadDataOp}...`, 'info'); + const result = await api('POST', `/versions/${state.selectedVersionId}/${state.loadDataOp}`, body); + document.getElementById('load-data-form').classList.add('hidden'); + showStatus(`${state.loadDataOp} loaded — ${result.rows_affected} rows`, 'success'); + } catch (err) { + showStatus(err.message, 'error'); + } +} + +async function toggleVersionStatus() { + if (!state.selectedVersionId) return; + // find current status from grid + let currentStatus = null; + state.grids.versions.forEachNode(n => { + if (n.data.id === state.selectedVersionId) currentStatus = n.data.status; + }); + try { + const route = currentStatus === 'open' + ? `/versions/${state.selectedVersionId}/close` + : `/versions/${state.selectedVersionId}/reopen`; + const result = await api('POST', route, { pf_user: getPfUser() }); + showStatus(`Version ${result.status}`, 'success'); + await renderVersions(); + document.getElementById('version-actions').classList.add('hidden'); + } catch (err) { + showStatus(err.message, 'error'); + } +} + +async function deleteVersion() { + if (!state.selectedVersionId) return; + if (!confirm('Delete this version and its forecast table? This cannot be undone.')) return; + try { + await api('DELETE', `/versions/${state.selectedVersionId}`); + showStatus('Version deleted', 'success'); + if (state.version?.id === state.selectedVersionId) setVersion(null); + state.selectedVersionId = null; + document.getElementById('version-actions').classList.add('hidden'); + await renderVersions(); + } catch (err) { + showStatus(err.message, 'error'); + } +} + +function openForecast() { + if (!state.selectedVersionId) return; + // find the version object from grid + let v = null; + state.grids.versions.forEachNode(n => { + if (n.data.id === state.selectedVersionId) v = n.data; + }); + if (!v) return; + setVersion(v); + switchView('forecast'); +} + +/* ============================================================ + FORECAST VIEW — data loading + ============================================================ */ +async function loadForecastData() { + if (!state.version) return; + document.getElementById('forecast-label').textContent = + `${state.source?.tname || ''} — ${state.version.name} [${state.version.status}]`; + try { + // ensure col_meta is loaded (may not be if user navigated directly) + if (!state.colMeta.length && state.source) { + state.colMeta = await api('GET', `/sources/${state.source.id}/cols`); + } + showStatus('Loading forecast data...', 'info'); + const rawData = await api('GET', `/versions/${state.version.id}/data`); + const numericCols = state.colMeta + .filter(c => c.role === 'value' || c.role === 'units') + .map(c => c.cname); + const data = rawData.map(row => { + const r = { ...row }; + numericCols.forEach(col => { if (r[col] != null) r[col] = parseFloat(r[col]); }); + return r; + }); + initPivotGrid(data); + showStatus(`Loaded ${data.length.toLocaleString()} rows`, 'success'); + } catch (err) { + showStatus(err.message, 'error'); + } +} + +/* ============================================================ + FORECAST VIEW — pivot grid + ============================================================ */ +function buildPivotColDefs() { + const defs = []; + + state.colMeta.forEach((c) => { + if (c.role === 'ignore') return; + const needsGetter = /\W/.test(c.cname); + const def = { + field: c.cname, + headerName: c.label || c.cname, + resizable: true, + sortable: true, + ...(needsGetter ? { valueGetter: p => p.data ? p.data[c.cname] : undefined } : {}) + }; + if (c.role === 'dimension' || c.role === 'date') { + def.enableRowGroup = true; + def.enablePivot = true; + } + if (c.role === 'value' || c.role === 'units') { + def.enableValue = true; + def.aggFunc = 'sum'; + def.type = 'numericColumn'; + def.valueFormatter = p => p.value != null + ? Number(p.value).toLocaleString(undefined, { maximumFractionDigits: 2 }) + : ''; + } + defs.push(def); + + if (c.role === 'date') { + defs.push({ + colId: c.cname + '__month', + headerName: (c.label || c.cname) + ' (Month)', + enableRowGroup: true, + enablePivot: true, + valueGetter: p => { + const v = p.data ? p.data[c.cname] : undefined; + if (!v) return undefined; + const d = new Date(v); + return isNaN(d) ? undefined : `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; + } + }); + } + }); + + // always include iter for grouping context + defs.push({ field: 'iter', headerName: 'Iter', enableRowGroup: true, enablePivot: true, width: 90 }); + defs.push({ field: 'pf_user', headerName: 'User', width: 90, hide: true }); + defs.push({ field: 'created_at', headerName: 'Created', width: 130, hide: true, + valueFormatter: p => p.value ? new Date(p.value).toLocaleDateString() : '' }); + + return defs; +} + +function initPivotGrid(data) { + const el = document.getElementById('pivot-grid'); + + if (state.grids.pivot) { + state.grids.pivot.setGridOption('rowData', data); + return; + } + + state.grids.pivot = agGrid.createGrid(el, { + columnDefs: buildPivotColDefs(), + rowData: data, + rowSelection: 'single', + groupDisplayType: 'singleColumn', + rowGroupPanelShow: 'always', + groupDefaultExpanded: 1, + suppressAggFuncInHeader: true, + animateRows: true, + sideBar: { + toolPanels: [{ + id: 'columns', + labelDefault: 'Columns', + labelKey: 'columns', + iconKey: 'columns', + toolPanel: 'agColumnsToolPanel', + toolPanelParams: {} + }], + defaultToolPanel: 'columns' + }, + defaultColDef: { resizable: true, sortable: true }, + autoGroupColumnDef: { + headerName: 'Group', + minWidth: 200, + cellRendererParams: { suppressCount: false } + }, + headerHeight: 32, + rowHeight: 28, + onRowClicked: onPivotRowClicked + }); +} + +/* ============================================================ + FORECAST VIEW — slice selection + ============================================================ */ +function onPivotRowClicked(event) { + const node = event.node; + state.slice = extractSliceFromNode(node); + renderSliceDisplay(); + + // populate recode and clone fields whenever slice changes + renderDimFields('recode'); + renderDimFields('clone'); +} + +function extractSliceFromNode(node) { + const slice = {}; + let current = node; + while (current) { + if (current.field && current.key != null && current.key !== '') { + slice[current.field] = current.key; + } + current = current.parent; + } + return slice; +} + +function renderSliceDisplay() { + const display = document.getElementById('slice-display'); + const hasSlice = Object.keys(state.slice).length > 0; + + if (!hasSlice) { + display.innerHTML = 'Click a row to select a slice'; + document.getElementById('btn-clear-slice').classList.add('hidden'); + document.getElementById('op-forms-area').classList.add('hidden'); + return; + } + + display.innerHTML = Object.entries(state.slice) + .map(([k, v]) => `${k} = ${v}`) + .join(''); + + document.getElementById('btn-clear-slice').classList.remove('hidden'); + document.getElementById('op-forms-area').classList.remove('hidden'); +} + +function clearSlice() { + state.slice = {}; + renderSliceDisplay(); +} + +/* ============================================================ + FORECAST VIEW — operation tabs + ============================================================ */ +function switchOpTab(opName) { + document.querySelectorAll('.op-tab').forEach(t => + t.classList.toggle('active', t.dataset.op === opName) + ); + document.querySelectorAll('.op-form').forEach(f => f.classList.add('hidden')); + document.getElementById(`op-${opName}`).classList.remove('hidden'); +} + +// render input fields for each dimension column (recode / clone) +// is_key columns get a populated ${options}`; + } else { + input = ``; + } + + return ` +
+ +
`; + }).join(''); +} + +/* ============================================================ + FORECAST VIEW — submit operations + ============================================================ */ +async function submitScale() { + const value_incr = parseFloat(document.getElementById('scale-value-incr').value) || null; + const units_incr = parseFloat(document.getElementById('scale-units-incr').value) || null; + const pct = document.getElementById('scale-pct').checked; + const note = document.getElementById('scale-note').value.trim(); + + if (!value_incr && !units_incr) { + showStatus('Enter a value or units increment', 'error'); return; + } + if (Object.keys(state.slice).length === 0) { + showStatus('Select a slice first', 'error'); return; + } + try { + const result = await api('POST', `/versions/${state.version.id}/scale`, { + pf_user: getPfUser(), note: note || undefined, + slice: state.slice, value_incr, units_incr, pct + }); + showStatus(`Scale applied — ${result.rows_affected} rows inserted`, 'success'); + document.getElementById('scale-value-incr').value = ''; + document.getElementById('scale-units-incr').value = ''; + document.getElementById('scale-note').value = ''; + await loadForecastData(); + } catch (err) { + showStatus(err.message, 'error'); + } +} + +async function submitRecode() { + const set = {}; + document.querySelectorAll('#recode-fields input[data-col], #recode-fields select[data-col]').forEach(inp => { + if (inp.value.trim()) set[inp.dataset.col] = inp.value.trim(); + }); + if (Object.keys(set).length === 0) { + showStatus('Enter at least one new dimension value', 'error'); return; + } + if (Object.keys(state.slice).length === 0) { + showStatus('Select a slice first', 'error'); return; + } + try { + const result = await api('POST', `/versions/${state.version.id}/recode`, { + pf_user: getPfUser(), + note: document.getElementById('recode-note').value.trim() || undefined, + slice: state.slice, + set + }); + showStatus(`Recode applied — ${result.rows_affected} rows inserted`, 'success'); + document.querySelectorAll('#recode-fields input[data-col], #recode-fields select[data-col]').forEach(i => { i.value = ''; }); + await loadForecastData(); + } catch (err) { + showStatus(err.message, 'error'); + } +} + +async function submitClone() { + const set = {}; + document.querySelectorAll('#clone-fields input[data-col], #clone-fields select[data-col]').forEach(inp => { + if (inp.value.trim()) set[inp.dataset.col] = inp.value.trim(); + }); + if (Object.keys(set).length === 0) { + showStatus('Enter at least one new dimension value', 'error'); return; + } + if (Object.keys(state.slice).length === 0) { + showStatus('Select a slice first', 'error'); return; + } + try { + const scale = parseFloat(document.getElementById('clone-scale').value) || 1.0; + const result = await api('POST', `/versions/${state.version.id}/clone`, { + pf_user: getPfUser(), + note: document.getElementById('clone-note').value.trim() || undefined, + slice: state.slice, + set, + scale + }); + showStatus(`Clone applied — ${result.rows_affected} rows inserted`, 'success'); + document.querySelectorAll('#clone-fields input[data-col], #clone-fields select[data-col]').forEach(i => { i.value = ''; }); + document.getElementById('clone-scale').value = '1'; + await loadForecastData(); + } catch (err) { + showStatus(err.message, 'error'); + } +} + +/* ============================================================ + LOG VIEW + ============================================================ */ +async function loadLogData() { + if (!state.version) return; + try { + const logs = await api('GET', `/versions/${state.version.id}/log`); + renderLogGrid(logs); + } catch (err) { + showStatus(err.message, 'error'); + } +} + +function renderLogGrid(logs) { + const el = document.getElementById('log-grid'); + + const colDefs = [ + { field: 'id', headerName: 'ID', width: 65 }, + { field: 'pf_user', headerName: 'User', width: 90 }, + { field: 'stamp', headerName: 'Time', width: 140, + valueFormatter: p => p.value ? new Date(p.value).toLocaleString() : '' }, + { field: 'operation', headerName: 'Operation', width: 90 }, + { field: 'slice', headerName: 'Slice', flex: 1, + valueFormatter: p => p.value ? JSON.stringify(p.value) : '' }, + { field: 'note', headerName: 'Note', flex: 1 }, + { + headerName: '', + width: 70, + cellRenderer: p => { + const btn = document.createElement('button'); + btn.className = 'btn btn-sm btn-danger'; + btn.textContent = 'Undo'; + btn.dataset.logid = p.data.id; + return btn; + }, + sortable: false + } + ]; + + if (state.grids.log) { + state.grids.log.setGridOption('rowData', logs); + state.grids.log.setGridOption('columnDefs', colDefs); + return; + } + + state.grids.log = agGrid.createGrid(el, { + columnDefs: colDefs, + rowData: logs, + defaultColDef: { resizable: true, sortable: true }, + headerHeight: 32, + rowHeight: 28 + }); +} + +async function undoOperation(logid) { + if (!confirm(`Undo operation ${logid}? This will delete the associated forecast rows.`)) return; + try { + const result = await api('DELETE', `/log/${logid}`); + showStatus(`Undone — ${result.rows_deleted} rows removed`, 'success'); + await loadLogData(); + // refresh forecast grid if open + if (state.view === 'forecast') await loadForecastData(); + } catch (err) { + showStatus(err.message, 'error'); + } +} + +/* ============================================================ + INIT + ============================================================ */ +document.addEventListener('DOMContentLoaded', () => { + + // restore pf_user from localStorage + const savedUser = localStorage.getItem('pf_user'); + if (savedUser) document.getElementById('input-pf-user').value = savedUser; + document.getElementById('input-pf-user').addEventListener('change', e => { + localStorage.setItem('pf_user', e.target.value.trim()); + }); + + // navigation + document.querySelectorAll('.nav-links li').forEach(li => { + li.addEventListener('click', () => switchView(li.dataset.view)); + }); + + // sources view buttons + document.getElementById('btn-register').addEventListener('click', () => { + if (state.previewSchema && state.previewTname) { + registerTable(state.previewSchema, state.previewTname); + } + }); + document.getElementById('btn-back-sources').addEventListener('click', backToSources); + document.getElementById('btn-save-cols').addEventListener('click', saveColMeta); + document.getElementById('btn-generate-sql').addEventListener('click', generateSQL); + + // modal + document.getElementById('modal-close').addEventListener('click', () => + document.getElementById('modal-overlay').classList.add('hidden') + ); + document.getElementById('btn-modal-close').addEventListener('click', () => + document.getElementById('modal-overlay').classList.add('hidden') + ); + document.getElementById('btn-modal-register').addEventListener('click', () => { + if (state.previewSchema && state.previewTname) { + registerTable(state.previewSchema, state.previewTname); + } + }); + + // versions view buttons + document.getElementById('btn-new-version').addEventListener('click', showNewVersionForm); + document.getElementById('btn-create-version').addEventListener('click', createVersion); + document.getElementById('btn-cancel-version').addEventListener('click', () => { + document.getElementById('new-version-form').classList.add('hidden'); + }); + document.getElementById('vbtn-forecast').addEventListener('click', openForecast); + document.getElementById('vbtn-baseline').addEventListener('click', () => showLoadForm('baseline')); + document.getElementById('vbtn-reference').addEventListener('click', () => showLoadForm('reference')); + document.getElementById('vbtn-toggle').addEventListener('click', toggleVersionStatus); + document.getElementById('vbtn-delete').addEventListener('click', deleteVersion); + document.getElementById('btn-load-submit').addEventListener('click', submitLoadData); + document.getElementById('btn-load-cancel').addEventListener('click', () => { + document.getElementById('load-data-form').classList.add('hidden'); + }); + + // forecast view buttons + document.getElementById('btn-forecast-refresh').addEventListener('click', loadForecastData); + document.getElementById('btn-expand-all').addEventListener('click', () => state.grids.pivot?.expandAll()); + document.getElementById('btn-collapse-all').addEventListener('click', () => state.grids.pivot?.collapseAll()); + document.getElementById('btn-clear-slice').addEventListener('click', clearSlice); + + document.querySelectorAll('.op-tab').forEach(tab => { + tab.addEventListener('click', () => switchOpTab(tab.dataset.op)); + }); + + document.getElementById('btn-submit-scale').addEventListener('click', submitScale); + document.getElementById('btn-submit-recode').addEventListener('click', submitRecode); + document.getElementById('btn-submit-clone').addEventListener('click', submitClone); + + // undo button delegation on log grid + document.getElementById('log-grid').addEventListener('click', e => { + const btn = e.target.closest('.btn-danger[data-logid]'); + if (btn) undoOperation(parseInt(btn.dataset.logid)); + }); + + // init sources view + initSourcesView().catch(err => showStatus(err.message, 'error')); +}); diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..16d62f2 --- /dev/null +++ b/public/index.html @@ -0,0 +1,191 @@ + + + + + + Pivot Forecast + + + + + +
+ + + + + +
+ + + +
+
+
+
+ Database Tables +
+ +
+
+
+
+
+
+ Registered Sources +
+ + + +
+
+
+ +
+
+
+ + + + + + + + + + +
+
+ + + + + + + + diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..39648ba --- /dev/null +++ b/public/styles.css @@ -0,0 +1,320 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-size: 13px; + height: 100vh; + overflow: hidden; + background: #f0f2f5; + color: #333; +} + +/* ============================================================ + LAYOUT + ============================================================ */ +#app { display: flex; height: 100vh; } +#content { flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0; } + +/* ============================================================ + SIDEBAR + ============================================================ */ +#sidebar { + width: 200px; + flex-shrink: 0; + background: #2c3e50; + color: #ecf0f1; + display: flex; + flex-direction: column; +} +.sidebar-brand { + padding: 16px; + font-size: 15px; + font-weight: 700; + border-bottom: 1px solid #3d5166; + letter-spacing: 0.02em; +} +.nav-links { list-style: none; padding: 6px 0; } +.nav-links li { + padding: 10px 16px; + cursor: pointer; + font-size: 13px; + color: #bdc3c7; + transition: background 0.1s; +} +.nav-links li:hover { background: #3d5166; color: #ecf0f1; } +.nav-links li.active { background: #1a252f; color: #ecf0f1; border-left: 3px solid #3498db; padding-left: 13px; } + +.sidebar-context { flex: 1; padding: 12px 16px; border-top: 1px solid #3d5166; } +.ctx-item { margin-bottom: 10px; } +.ctx-item.hidden { display: none; } +.ctx-label { display: block; font-size: 10px; color: #7f8c8d; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 2px; } +.ctx-item span:not(.ctx-label) { font-size: 12px; word-break: break-word; } +.status-badge { display: inline-block; padding: 1px 7px; border-radius: 10px; font-size: 10px; margin-left: 4px; vertical-align: middle; } +.status-badge.open { background: #27ae60; } +.status-badge.closed { background: #c0392b; } + +.sidebar-user { padding: 12px 16px; border-top: 1px solid #3d5166; } +.sidebar-user label { display: block; font-size: 10px; color: #7f8c8d; text-transform: uppercase; margin-bottom: 4px; } +.sidebar-user input { + width: 100%; + background: #3d5166; + border: 1px solid #4a6278; + color: #ecf0f1; + padding: 5px 8px; + border-radius: 3px; + font-size: 12px; +} +.sidebar-user input::placeholder { color: #7f8c8d; } + +/* ============================================================ + STATUS BAR + ============================================================ */ +#status-bar { padding: 7px 16px; font-size: 12px; flex-shrink: 0; } +#status-bar.hidden { display: none; } +.status-info { background: #d6eaf8; color: #1a5276; border-bottom: 1px solid #aed6f1; } +.status-success { background: #d5f5e3; color: #1e8449; border-bottom: 1px solid #a9dfbf; } +.status-error { background: #fadbd8; color: #922b21; border-bottom: 1px solid #f1948a; } + +/* ============================================================ + VIEWS + ============================================================ */ +.view { flex: 1; overflow: hidden; padding: 12px; display: none; flex-direction: column; gap: 8px; } +.view.active { display: flex; } +.view.hidden { display: none !important; } + +/* ============================================================ + SOURCES VIEW — two-column + ============================================================ */ +.two-col-layout { display: flex; gap: 10px; flex: 1; overflow: hidden; } +.panel { + flex: 1; + background: white; + border-radius: 5px; + display: flex; + flex-direction: column; + overflow: hidden; + box-shadow: 0 1px 3px rgba(0,0,0,.08); + min-width: 0; +} +.panel-header { + padding: 9px 12px; + border-bottom: 1px solid #e8ecf0; + display: flex; + align-items: center; + justify-content: space-between; + background: #f8fafc; + font-weight: 600; + font-size: 12px; + flex-shrink: 0; +} +.header-actions { display: flex; gap: 6px; align-items: center; } +.grid-fill { flex: 1; min-height: 0; } + +/* ============================================================ + VERSIONS VIEW + ============================================================ */ +.view-toolbar { + display: flex; + align-items: center; + gap: 10px; + flex-shrink: 0; +} +#versions-source-label { font-weight: 600; font-size: 13px; flex: 1; } +#versions-grid { flex: 1; min-height: 200px; } + +.version-actions { + background: white; + border-radius: 5px; + padding: 10px 14px; + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; + box-shadow: 0 1px 3px rgba(0,0,0,.08); + flex-wrap: wrap; +} +.version-actions.hidden { display: none; } +#version-actions-label { font-size: 12px; font-weight: 600; flex: 1; min-width: 120px; } + +.inline-form { + background: white; + border-radius: 5px; + padding: 14px 16px; + box-shadow: 0 1px 3px rgba(0,0,0,.08); + flex-shrink: 0; +} +.inline-form h3 { font-size: 13px; margin-bottom: 10px; font-weight: 600; } +.inline-form.hidden { display: none; } + +.form-row { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 8px; } +.form-row label, .inline-form label { + font-size: 11px; + color: #555; + display: flex; + flex-direction: column; + gap: 3px; +} +.form-row input[type=text], +.form-row input[type=date], +.form-row input[type=number], +.inline-form input[type=text], +.inline-form input[type=date], +.inline-form input[type=number] { + border: 1px solid #dce1e7; + padding: 5px 8px; + border-radius: 3px; + font-size: 12px; + min-width: 140px; + color: #333; +} +.form-actions { display: flex; gap: 8px; } + +/* ============================================================ + FORECAST VIEW + ============================================================ */ +.forecast-toolbar { + display: flex; + align-items: center; + gap: 10px; + flex-shrink: 0; +} +#forecast-label { font-weight: 600; font-size: 13px; flex: 1; } + +.forecast-layout { display: flex; gap: 10px; flex: 1; overflow: hidden; min-height: 0; } + +#pivot-panel { + flex: 1; + background: white; + border-radius: 5px; + overflow: hidden; + box-shadow: 0 1px 3px rgba(0,0,0,.08); + min-width: 0; +} +#pivot-grid { width: 100%; height: 100%; } + +#operation-panel { + width: 270px; + flex-shrink: 0; + background: white; + border-radius: 5px; + padding: 14px; + box-shadow: 0 1px 3px rgba(0,0,0,.08); + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 12px; +} +.op-section { display: flex; flex-direction: column; gap: 6px; } +.op-title { font-size: 10px; font-weight: 700; text-transform: uppercase; color: #7f8c8d; letter-spacing: 0.05em; } +.op-hint { font-size: 11px; color: #aaa; } + +#slice-display { display: flex; flex-wrap: wrap; gap: 4px; min-height: 24px; } +.slice-tag { + background: #eaf4fb; + color: #1a6fa8; + padding: 2px 10px; + border-radius: 12px; + font-size: 11px; + cursor: default; +} + +.op-tabs { display: flex; border-bottom: 2px solid #eee; margin-bottom: 10px; } +.op-tab { + background: none; + border: none; + padding: 6px 14px; + font-size: 12px; + cursor: pointer; + color: #666; + border-bottom: 2px solid transparent; + margin-bottom: -2px; +} +.op-tab.active { color: #2980b9; border-bottom-color: #2980b9; font-weight: 600; } +.op-tab:hover:not(.active) { color: #333; } + +.op-form { display: flex; flex-direction: column; gap: 8px; } +.op-form.hidden { display: none; } +.op-form label { font-size: 11px; color: #555; display: flex; flex-direction: column; gap: 3px; } +.op-form input[type=text], +.op-form input[type=number] { + border: 1px solid #dce1e7; + padding: 5px 8px; + border-radius: 3px; + font-size: 12px; +} +.label-inline { flex-direction: row !important; align-items: center; gap: 6px !important; font-size: 12px !important; } +#op-forms-area { display: flex; flex-direction: column; gap: 0; } +#op-forms-area.hidden { display: none; } + +.recode-field, .clone-field { margin-bottom: 8px; } +.recode-field label, .clone-field label { font-size: 11px; color: #555; display: block; margin-bottom: 2px; } +.field-hint { font-size: 10px; color: #aaa; } + +/* ============================================================ + LOG VIEW + ============================================================ */ +#log-grid { flex: 1; min-height: 0; } + +/* ============================================================ + BUTTONS + ============================================================ */ +.btn { + padding: 5px 12px; + border: 1px solid #dce1e7; + background: white; + border-radius: 3px; + cursor: pointer; + font-size: 12px; + white-space: nowrap; + color: #333; +} +.btn:hover { background: #f0f2f5; } +.btn:active { background: #e8ecf0; } +.btn-primary { background: #2980b9; color: white; border-color: #2574a9; } +.btn-primary:hover { background: #2574a9; } +.btn-danger { background: #e74c3c; color: white; border-color: #d44332; } +.btn-danger:hover { background: #d44332; } +.btn-sm { padding: 2px 8px; font-size: 11px; } +.btn.hidden { display: none; } +.btn-icon { background: none; border: none; font-size: 20px; cursor: pointer; color: #666; line-height: 1; padding: 0 4px; } +.btn-icon:hover { color: #333; } + +/* ============================================================ + MODAL + ============================================================ */ +.modal-overlay { + position: fixed; inset: 0; + background: rgba(0,0,0,.45); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} +.modal-overlay.hidden { display: none; } +.modal { + background: white; + border-radius: 6px; + width: 720px; + max-height: 80vh; + display: flex; + flex-direction: column; + box-shadow: 0 10px 40px rgba(0,0,0,.2); +} +.modal-header { + padding: 14px 18px; + border-bottom: 1px solid #eee; + display: flex; + justify-content: space-between; + align-items: center; + font-weight: 600; + font-size: 13px; +} +#modal-body { padding: 16px 18px; overflow-y: auto; flex: 1; font-size: 12px; } +.modal-footer { padding: 10px 18px; border-top: 1px solid #eee; display: flex; justify-content: flex-end; gap: 8px; } + +.preview-section h4 { font-size: 12px; margin-bottom: 6px; color: #555; } +.preview-section + .preview-section { margin-top: 16px; } +.preview-table { border-collapse: collapse; width: 100%; font-size: 11px; } +.preview-table th, .preview-table td { border: 1px solid #e0e0e0; padding: 4px 8px; text-align: left; } +.preview-table th { background: #f5f7f9; font-weight: 600; } +.preview-table tr:hover td { background: #fafbfc; } diff --git a/routes/log.js b/routes/log.js new file mode 100644 index 0000000..3639b60 --- /dev/null +++ b/routes/log.js @@ -0,0 +1,63 @@ +const express = require('express'); +const { fcTable } = require('../lib/utils'); + +module.exports = function(pool) { + const router = express.Router(); + + // list all log entries for a version, newest first + router.get('/versions/:id/log', async (req, res) => { + try { + const result = await pool.query( + `SELECT * FROM pf.log WHERE version_id = $1 ORDER BY stamp DESC`, + [req.params.id] + ); + res.json(result.rows); + } catch (err) { + console.error(err); + res.status(500).json({ error: err.message }); + } + }); + + // undo an operation — deletes all forecast rows with this logid, then the log entry + // two separate queries in a transaction to avoid FK ordering issues + router.delete('/log/:logid', async (req, res) => { + const logid = parseInt(req.params.logid); + const client = await pool.connect(); + try { + // look up the log entry to find the version and fc_table name + const logResult = await client.query(` + SELECT l.*, v.id AS version_id, s.tname + FROM pf.log l + JOIN pf.version v ON v.id = l.version_id + JOIN pf.source s ON s.id = v.source_id + WHERE l.id = $1 + `, [logid]); + + if (logResult.rows.length === 0) { + return res.status(404).json({ error: 'Log entry not found' }); + } + + const { tname, version_id } = logResult.rows[0]; + const table = fcTable(tname, version_id); + + await client.query('BEGIN'); + // delete forecast rows first (logid has no FK constraint — managed by app) + const del = await client.query( + `DELETE FROM ${table} WHERE logid = $1 RETURNING id`, + [logid] + ); + await client.query(`DELETE FROM pf.log WHERE id = $1`, [logid]); + await client.query('COMMIT'); + + res.json({ message: 'Operation undone', rows_deleted: del.rowCount }); + } catch (err) { + await client.query('ROLLBACK'); + console.error(err); + res.status(500).json({ error: err.message }); + } finally { + client.release(); + } + }); + + return router; +}; diff --git a/routes/operations.js b/routes/operations.js new file mode 100644 index 0000000..f3baba0 --- /dev/null +++ b/routes/operations.js @@ -0,0 +1,266 @@ +const express = require('express'); +const { applyTokens, buildWhere, buildExcludeClause, buildSetClause, esc } = require('../lib/sql_generator'); +const { fcTable } = require('../lib/utils'); + +module.exports = function(pool) { + const router = express.Router(); + + async function runSQL(sql) { + console.log('--- SQL ---\n', sql, '\n--- END SQL ---'); + return pool.query(sql); + } + + // fetch everything needed to execute an operation: + // version + source info, col_meta, fc_table name, stored SQL + async function getContext(versionId, operation) { + const verResult = await pool.query(` + SELECT v.*, s.schema, s.tname, s.id AS source_id + FROM pf.version v + JOIN pf.source s ON s.id = v.source_id + WHERE v.id = $1 + `, [versionId]); + if (verResult.rows.length === 0) { + const err = new Error('Version not found'); err.status = 404; throw err; + } + const version = verResult.rows[0]; + + const colResult = await pool.query( + `SELECT * FROM pf.col_meta WHERE source_id = $1 ORDER BY opos`, + [version.source_id] + ); + const colMeta = colResult.rows; + const dimCols = colMeta.filter(c => c.role === 'dimension').map(c => c.cname); + const valueCol = colMeta.find(c => c.role === 'value')?.cname; + const unitsCol = colMeta.find(c => c.role === 'units')?.cname; + + const sqlResult = await pool.query( + `SELECT sql FROM pf.sql WHERE source_id = $1 AND operation = $2`, + [version.source_id, operation] + ); + if (sqlResult.rows.length === 0) { + const err = new Error(`No generated SQL for operation "${operation}" — run generate-sql first`); + err.status = 400; throw err; + } + + return { + version, + table: fcTable(version.tname, version.id), + colMeta, + dimCols, + valueCol, + unitsCol, + sql: sqlResult.rows[0].sql + }; + } + + function guardOpen(version, res) { + if (version.status === 'closed') { + res.status(403).json({ error: 'Version is closed' }); + return false; + } + return true; + } + + // fetch all rows for a version (all iters including reference) + router.get('/versions/:id/data', async (req, res) => { + try { + const ctx = await getContext(parseInt(req.params.id), 'get_data'); + const sql = applyTokens(ctx.sql, { fc_table: ctx.table }); + const result = await runSQL(sql); + res.json(result.rows); + } catch (err) { + console.error(err); + res.status(err.status || 500).json({ error: err.message }); + } + }); + + // load baseline rows from source table for a date range + // deletes existing iter='baseline' rows before inserting (handled inside stored SQL) + router.post('/versions/:id/baseline', async (req, res) => { + const { date_from, date_to, pf_user, note, replay } = req.body; + if (!date_from || !date_to) { + return res.status(400).json({ error: 'date_from and date_to are required' }); + } + if (replay) { + return res.status(501).json({ error: 'replay is not yet implemented' }); + } + try { + const ctx = await getContext(parseInt(req.params.id), 'baseline'); + if (!guardOpen(ctx.version, res)) return; + + const sql = applyTokens(ctx.sql, { + fc_table: ctx.table, + version_id: ctx.version.id, + pf_user: esc(pf_user || ''), + note: esc(note || ''), + params: esc(JSON.stringify({ date_from, date_to })), + date_from: esc(date_from), + date_to: esc(date_to) + }); + + const result = await runSQL(sql); + res.json(result.rows[0]); + } catch (err) { + console.error(err); + res.status(err.status || 500).json({ error: err.message }); + } + }); + + // load reference rows from source table (additive — does not clear prior reference rows) + router.post('/versions/:id/reference', async (req, res) => { + const { date_from, date_to, pf_user, note } = req.body; + if (!date_from || !date_to) { + return res.status(400).json({ error: 'date_from and date_to are required' }); + } + try { + const ctx = await getContext(parseInt(req.params.id), 'reference'); + if (!guardOpen(ctx.version, res)) return; + + const sql = applyTokens(ctx.sql, { + fc_table: ctx.table, + version_id: ctx.version.id, + pf_user: esc(pf_user || ''), + note: esc(note || ''), + params: esc(JSON.stringify({ date_from, date_to })), + date_from: esc(date_from), + date_to: esc(date_to) + }); + + const result = await runSQL(sql); + res.json(result.rows[0]); + } catch (err) { + console.error(err); + res.status(err.status || 500).json({ error: err.message }); + } + }); + + // scale a slice — adjust value and/or units by absolute amount or percentage + router.post('/versions/:id/scale', async (req, res) => { + const { pf_user, note, slice, value_incr, units_incr, pct } = req.body; + if (!slice || Object.keys(slice).length === 0) { + return res.status(400).json({ error: 'slice is required' }); + } + try { + const ctx = await getContext(parseInt(req.params.id), 'scale'); + if (!guardOpen(ctx.version, res)) return; + + const whereClause = buildWhere(slice, ctx.dimCols); + const excludeClause = buildExcludeClause(ctx.version.exclude_iters); + + let absValueIncr = value_incr || 0; + let absUnitsIncr = units_incr || 0; + + // pct mode: run a quick totals query, convert percentages to absolutes + if (pct && (value_incr || units_incr)) { + const totals = await pool.query(` + SELECT + sum("${ctx.valueCol}") AS total_value, + sum("${ctx.unitsCol}") AS total_units + FROM ${ctx.table} + WHERE ${whereClause} + ${excludeClause} + `); + const { total_value, total_units } = totals.rows[0]; + if (value_incr) absValueIncr = (parseFloat(total_value) || 0) * value_incr / 100; + if (units_incr) absUnitsIncr = (parseFloat(total_units) || 0) * units_incr / 100; + } + + if (absValueIncr === 0 && absUnitsIncr === 0) { + return res.status(400).json({ error: 'value_incr and/or units_incr must be non-zero' }); + } + + const sql = applyTokens(ctx.sql, { + fc_table: ctx.table, + version_id: ctx.version.id, + pf_user: esc(pf_user || ''), + note: esc(note || ''), + params: esc(JSON.stringify({ slice, value_incr, units_incr, pct })), + slice: esc(JSON.stringify(slice)), + where_clause: whereClause, + exclude_clause: excludeClause, + value_incr: absValueIncr, + units_incr: absUnitsIncr + }); + + const result = await runSQL(sql); + res.json(result.rows[0]); + } catch (err) { + console.error(err); + res.status(err.status || 500).json({ error: err.message }); + } + }); + + // recode dimension values on a slice + // inserts negative rows to zero out the original, positive rows with new dimension values + router.post('/versions/:id/recode', async (req, res) => { + const { pf_user, note, slice, set } = req.body; + if (!slice || Object.keys(slice).length === 0) return res.status(400).json({ error: 'slice is required' }); + if (!set || Object.keys(set).length === 0) return res.status(400).json({ error: 'set is required' }); + + try { + const ctx = await getContext(parseInt(req.params.id), 'recode'); + if (!guardOpen(ctx.version, res)) return; + + const whereClause = buildWhere(slice, ctx.dimCols); + const excludeClause = buildExcludeClause(ctx.version.exclude_iters); + const setClause = buildSetClause(ctx.dimCols, set); + + const sql = applyTokens(ctx.sql, { + fc_table: ctx.table, + version_id: ctx.version.id, + pf_user: esc(pf_user || ''), + note: esc(note || ''), + params: esc(JSON.stringify({ slice, set })), + slice: esc(JSON.stringify(slice)), + where_clause: whereClause, + exclude_clause: excludeClause, + set_clause: setClause + }); + + const result = await runSQL(sql); + res.json(result.rows[0]); + } catch (err) { + console.error(err); + res.status(err.status || 500).json({ error: err.message }); + } + }); + + // clone a slice as new business under new dimension values + // does not offset the original slice + router.post('/versions/:id/clone', async (req, res) => { + const { pf_user, note, slice, set, scale } = req.body; + if (!slice || Object.keys(slice).length === 0) return res.status(400).json({ error: 'slice is required' }); + if (!set || Object.keys(set).length === 0) return res.status(400).json({ error: 'set is required' }); + + try { + const ctx = await getContext(parseInt(req.params.id), 'clone'); + if (!guardOpen(ctx.version, res)) return; + + const scaleFactor = (scale != null) ? parseFloat(scale) : 1.0; + const whereClause = buildWhere(slice, ctx.dimCols); + const excludeClause = buildExcludeClause(ctx.version.exclude_iters); + const setClause = buildSetClause(ctx.dimCols, set); + + const sql = applyTokens(ctx.sql, { + fc_table: ctx.table, + version_id: ctx.version.id, + pf_user: esc(pf_user || ''), + note: esc(note || ''), + params: esc(JSON.stringify({ slice, set, scale: scaleFactor })), + slice: esc(JSON.stringify(slice)), + where_clause: whereClause, + exclude_clause: excludeClause, + set_clause: setClause, + scale_factor: scaleFactor + }); + + const result = await runSQL(sql); + res.json(result.rows[0]); + } catch (err) { + console.error(err); + res.status(err.status || 500).json({ error: err.message }); + } + }); + + return router; +}; diff --git a/routes/sources.js b/routes/sources.js new file mode 100644 index 0000000..8739fc3 --- /dev/null +++ b/routes/sources.js @@ -0,0 +1,243 @@ +const express = require('express'); +const { generateSQL } = require('../lib/sql_generator'); + +module.exports = function(pool) { + const router = express.Router(); + + // list all registered sources + router.get('/sources', async (req, res) => { + try { + const result = await pool.query( + `SELECT * FROM pf.source ORDER BY schema, tname` + ); + res.json(result.rows); + } catch (err) { + console.error(err); + res.status(500).json({ error: err.message }); + } + }); + + // register a source table + // auto-populates col_meta from information_schema with role='ignore' + router.post('/sources', async (req, res) => { + const { schema, tname, label, created_by } = req.body; + if (!schema || !tname) { + return res.status(400).json({ error: 'schema and tname are required' }); + } + if (!/^\w+$/.test(schema) || !/^\w+$/.test(tname)) { + return res.status(400).json({ error: 'Invalid schema or table name' }); + } + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + const src = await client.query( + `INSERT INTO pf.source (schema, tname, label, created_by) + VALUES ($1, $2, $3, $4) + RETURNING *`, + [schema, tname, label || null, created_by || null] + ); + const source = src.rows[0]; + + // seed col_meta from information_schema + await client.query(` + INSERT INTO pf.col_meta (source_id, cname, role, opos) + SELECT $1, column_name, 'ignore', ordinal_position + FROM information_schema.columns + WHERE table_schema = $2 AND table_name = $3 + ORDER BY ordinal_position + ON CONFLICT (source_id, cname) DO NOTHING + `, [source.id, schema, tname]); + + await client.query('COMMIT'); + res.status(201).json(source); + } catch (err) { + await client.query('ROLLBACK'); + console.error(err); + if (err.code === '23505') { + return res.status(409).json({ error: 'Source already registered' }); + } + res.status(500).json({ error: err.message }); + } finally { + client.release(); + } + }); + + // get col_meta for a source + router.get('/sources/:id/cols', async (req, res) => { + try { + const result = await pool.query( + `SELECT * FROM pf.col_meta WHERE source_id = $1 ORDER BY opos`, + [req.params.id] + ); + res.json(result.rows); + } catch (err) { + console.error(err); + res.status(500).json({ error: err.message }); + } + }); + + // save col_meta — accepts full array, upserts each row + router.put('/sources/:id/cols', async (req, res) => { + const sourceId = parseInt(req.params.id); + const cols = req.body; + if (!Array.isArray(cols)) { + return res.status(400).json({ error: 'body must be an array' }); + } + const client = await pool.connect(); + try { + await client.query('BEGIN'); + for (const col of cols) { + await client.query(` + INSERT INTO pf.col_meta (source_id, cname, label, role, is_key, opos) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (source_id, cname) DO UPDATE SET + label = EXCLUDED.label, + role = EXCLUDED.role, + is_key = EXCLUDED.is_key, + opos = EXCLUDED.opos + `, [ + sourceId, + col.cname, + col.label || null, + col.role || 'ignore', + col.is_key || false, + col.opos || null + ]); + } + await client.query('COMMIT'); + const result = await pool.query( + `SELECT * FROM pf.col_meta WHERE source_id = $1 ORDER BY opos`, + [sourceId] + ); + res.json(result.rows); + } catch (err) { + await client.query('ROLLBACK'); + console.error(err); + res.status(500).json({ error: err.message }); + } finally { + client.release(); + } + }); + + // generate SQL for all operations from current col_meta and store in pf.sql + router.post('/sources/:id/generate-sql', async (req, res) => { + const sourceId = parseInt(req.params.id); + try { + const srcResult = await pool.query( + `SELECT * FROM pf.source WHERE id = $1`, [sourceId] + ); + if (srcResult.rows.length === 0) { + return res.status(404).json({ error: 'Source not found' }); + } + + const colResult = await pool.query( + `SELECT * FROM pf.col_meta WHERE source_id = $1 ORDER BY opos`, + [sourceId] + ); + + // validate required roles + const colMeta = colResult.rows; + const roles = new Set(colMeta.map(c => c.role)); + const missing = ['value', 'units', 'date'].filter(r => !roles.has(r)); + if (missing.length > 0) { + return res.status(400).json({ + error: `col_meta is missing required roles: ${missing.join(', ')}` + }); + } + if (!colMeta.some(c => c.role === 'dimension')) { + return res.status(400).json({ error: 'col_meta has no dimension columns' }); + } + + const sqls = generateSQL(srcResult.rows[0], colMeta); + const client = await pool.connect(); + try { + await client.query('BEGIN'); + for (const [operation, sql] of Object.entries(sqls)) { + await client.query(` + INSERT INTO pf.sql (source_id, operation, sql, generated_at) + VALUES ($1, $2, $3, now()) + ON CONFLICT (source_id, operation) DO UPDATE SET + sql = EXCLUDED.sql, + generated_at = EXCLUDED.generated_at + `, [sourceId, operation, sql]); + } + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } + + res.json({ message: 'SQL generated', operations: Object.keys(sqls) }); + } catch (err) { + console.error(err); + res.status(500).json({ error: err.message }); + } + }); + + // view generated SQL for a source (for inspection / debug) + router.get('/sources/:id/sql', async (req, res) => { + try { + const result = await pool.query( + `SELECT operation, sql, generated_at + FROM pf.sql WHERE source_id = $1 ORDER BY operation`, + [req.params.id] + ); + res.json(result.rows); + } catch (err) { + console.error(err); + res.status(500).json({ error: err.message }); + } + }); + + // get distinct values for a key column (used to populate operation panel dropdowns) + router.get('/sources/:id/values/:col', async (req, res) => { + const col = req.params.col; + try { + const srcResult = await pool.query( + `SELECT schema, tname FROM pf.source WHERE id = $1`, [req.params.id] + ); + if (srcResult.rows.length === 0) return res.status(404).json({ error: 'Source not found' }); + + // validate col is a key dimension on this source + const metaResult = await pool.query( + `SELECT 1 FROM pf.col_meta WHERE source_id = $1 AND cname = $2 AND is_key = true`, + [req.params.id, col] + ); + if (metaResult.rows.length === 0) { + return res.status(400).json({ error: `"${col}" is not a key column` }); + } + + const { schema, tname } = srcResult.rows[0]; + const result = await pool.query( + `SELECT DISTINCT "${col}" AS val FROM ${schema}.${tname} + WHERE "${col}" IS NOT NULL ORDER BY "${col}"` + ); + res.json(result.rows.map(r => r.val)); + } catch (err) { + console.error(err); + res.status(500).json({ error: err.message }); + } + }); + + // deregister a source — does not drop existing forecast tables + router.delete('/sources/:id', async (req, res) => { + try { + const result = await pool.query( + `DELETE FROM pf.source WHERE id = $1 RETURNING *`, + [req.params.id] + ); + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Source not found' }); + } + res.json({ message: 'Source deregistered', source: result.rows[0] }); + } catch (err) { + console.error(err); + res.status(500).json({ error: err.message }); + } + }); + + return router; +}; diff --git a/routes/tables.js b/routes/tables.js new file mode 100644 index 0000000..2487645 --- /dev/null +++ b/routes/tables.js @@ -0,0 +1,53 @@ +const express = require('express'); + +module.exports = function(pool) { + const router = express.Router(); + + // list all non-system tables with row estimates + router.get('/tables', async (req, res) => { + try { + const result = await pool.query(` + SELECT + t.table_schema AS schema, + t.table_name AS tname, + c.reltuples::bigint AS row_estimate + FROM information_schema.tables t + LEFT JOIN pg_class c ON c.relname = t.table_name + LEFT JOIN pg_namespace n ON n.oid = c.relnamespace AND n.nspname = t.table_schema + WHERE t.table_schema NOT IN ('pg_catalog', 'information_schema', 'pf') + ORDER BY t.table_schema, t.table_name + `); + res.json(result.rows); + } catch (err) { + console.error(err); + res.status(500).json({ error: err.message }); + } + }); + + // preview a table: column list + 5 sample rows + router.get('/tables/:schema/:tname/preview', async (req, res) => { + const { schema, tname } = req.params; + if (!/^\w+$/.test(schema) || !/^\w+$/.test(tname)) { + return res.status(400).json({ error: 'Invalid schema or table name' }); + } + try { + const cols = await pool.query(` + SELECT column_name, data_type, is_nullable, ordinal_position + FROM information_schema.columns + WHERE table_schema = $1 AND table_name = $2 + ORDER BY ordinal_position + `, [schema, tname]); + + const rows = await pool.query( + `SELECT * FROM ${schema}.${tname} LIMIT 5` + ); + + res.json({ columns: cols.rows, rows: rows.rows }); + } catch (err) { + console.error(err); + res.status(500).json({ error: err.message }); + } + }); + + return router; +}; diff --git a/routes/versions.js b/routes/versions.js new file mode 100644 index 0000000..746523e --- /dev/null +++ b/routes/versions.js @@ -0,0 +1,216 @@ +const express = require('express'); +const { fcTable, mapType } = require('../lib/utils'); + +module.exports = function(pool) { + const router = express.Router(); + + // list versions for a source + router.get('/sources/:id/versions', async (req, res) => { + try { + const result = await pool.query( + `SELECT * FROM pf.version WHERE source_id = $1 ORDER BY created_at DESC`, + [req.params.id] + ); + res.json(result.rows); + } catch (err) { + console.error(err); + res.status(500).json({ error: err.message }); + } + }); + + // create a new version + // inserts version row, then CREATE TABLE pf.fc_{tname}_{version_id} in one transaction + router.post('/sources/:id/versions', async (req, res) => { + const sourceId = parseInt(req.params.id); + const { name, description, created_by, exclude_iters } = req.body; + if (!name) return res.status(400).json({ error: 'name is required' }); + + const client = await pool.connect(); + try { + // fetch source + const srcResult = await client.query( + `SELECT * FROM pf.source WHERE id = $1`, [sourceId] + ); + if (srcResult.rows.length === 0) { + return res.status(404).json({ error: 'Source not found' }); + } + const source = srcResult.rows[0]; + + // fetch col_meta joined to information_schema for data types + const colResult = await client.query(` + SELECT + m.cname, + m.role, + m.opos, + i.data_type, + i.numeric_precision, + i.numeric_scale + FROM pf.col_meta m + JOIN information_schema.columns i + ON i.table_schema = $2 + AND i.table_name = $3 + AND i.column_name = m.cname + WHERE m.source_id = $1 + AND m.role NOT IN ('ignore') + ORDER BY m.opos + `, [sourceId, source.schema, source.tname]); + + if (colResult.rows.length === 0) { + return res.status(400).json({ + error: 'No usable columns in col_meta — configure roles before creating a version' + }); + } + + await client.query('BEGIN'); + + // insert version to get id + const verResult = await client.query(` + INSERT INTO pf.version (source_id, name, description, created_by, exclude_iters) + VALUES ($1, $2, $3, $4, $5) + RETURNING * + `, [ + sourceId, + name, + description || null, + created_by || null, + exclude_iters ? JSON.stringify(exclude_iters) : '["reference"]' + ]); + const version = verResult.rows[0]; + + // build CREATE TABLE DDL using col_meta + mapped data types + const table = fcTable(source.tname, version.id); + const colDefs = colResult.rows + .filter(c => c.cname !== 'id') + .map(c => { + const pgType = mapType(c.data_type, c.numeric_precision, c.numeric_scale); + const quoted = `"${c.cname}"`; + return ` ${quoted.padEnd(26)}${pgType}`; + }).join(',\n'); + + const ddl = ` + CREATE TABLE ${table} ( + id bigserial PRIMARY KEY, +${colDefs}, + iter text NOT NULL, + logid bigint NOT NULL, + pf_user text, + created_at timestamptz NOT NULL DEFAULT now() + ) + `; + await client.query(ddl); + + await client.query('COMMIT'); + res.status(201).json({ ...version, fc_table: table }); + } catch (err) { + await client.query('ROLLBACK'); + console.error(err); + if (err.code === '23505') { + return res.status(409).json({ error: 'A version with that name already exists for this source' }); + } + res.status(500).json({ error: err.message }); + } finally { + client.release(); + } + }); + + // update version name, description, or exclude_iters + router.put('/versions/:id', async (req, res) => { + const { name, description, exclude_iters } = req.body; + try { + const result = await pool.query(` + UPDATE pf.version SET + name = COALESCE($2, name), + description = COALESCE($3, description), + exclude_iters = COALESCE($4, exclude_iters) + WHERE id = $1 + RETURNING * + `, [ + req.params.id, + name || null, + description || null, + exclude_iters ? JSON.stringify(exclude_iters) : null + ]); + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Version not found' }); + } + res.json(result.rows[0]); + } catch (err) { + console.error(err); + res.status(500).json({ error: err.message }); + } + }); + + // close a version — blocks further edits + router.post('/versions/:id/close', async (req, res) => { + const { pf_user } = req.body; + try { + const result = await pool.query(` + UPDATE pf.version + SET status = 'closed', closed_at = now(), closed_by = $2 + WHERE id = $1 AND status = 'open' + RETURNING * + `, [req.params.id, pf_user || null]); + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Version not found or already closed' }); + } + res.json(result.rows[0]); + } catch (err) { + console.error(err); + res.status(500).json({ error: err.message }); + } + }); + + // reopen a closed version + router.post('/versions/:id/reopen', async (req, res) => { + try { + const result = await pool.query(` + UPDATE pf.version + SET status = 'open', closed_at = NULL, closed_by = NULL + WHERE id = $1 AND status = 'closed' + RETURNING * + `, [req.params.id]); + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Version not found or already open' }); + } + res.json(result.rows[0]); + } catch (err) { + console.error(err); + res.status(500).json({ error: err.message }); + } + }); + + // delete a version — drops forecast table then deletes version record + // log entries are removed by ON DELETE CASCADE on pf.log.version_id + router.delete('/versions/:id', async (req, res) => { + const versionId = parseInt(req.params.id); + const client = await pool.connect(); + try { + const verResult = await client.query(` + SELECT v.*, s.tname + FROM pf.version v + JOIN pf.source s ON s.id = v.source_id + WHERE v.id = $1 + `, [versionId]); + if (verResult.rows.length === 0) { + return res.status(404).json({ error: 'Version not found' }); + } + const { tname } = verResult.rows[0]; + const table = fcTable(tname, versionId); + + await client.query('BEGIN'); + await client.query(`DROP TABLE IF EXISTS ${table}`); + await client.query(`DELETE FROM pf.version WHERE id = $1`, [versionId]); + await client.query('COMMIT'); + + res.json({ message: 'Version deleted', fc_table: table }); + } catch (err) { + await client.query('ROLLBACK'); + console.error(err); + res.status(500).json({ error: err.message }); + } finally { + client.release(); + } + }); + + return router; +}; diff --git a/server.js b/server.js new file mode 100644 index 0000000..583fdf0 --- /dev/null +++ b/server.js @@ -0,0 +1,33 @@ +require('dotenv').config(); +const express = require('express'); +const cors = require('cors'); +const { Pool } = require('pg'); + +const app = express(); +app.use(cors()); +app.use(express.json()); +app.use(express.static('public')); + +const pool = new Pool({ + host: process.env.DB_HOST, + port: parseInt(process.env.DB_PORT) || 5432, + database: process.env.DB_NAME, + user: process.env.DB_USER, + password: process.env.DB_PASSWORD, + ssl: false +}); + +pool.on('error', (err) => { + console.error('pg pool error', err); +}); + +app.use('/api', require('./routes/tables')(pool)); +app.use('/api', require('./routes/sources')(pool)); +app.use('/api', require('./routes/versions')(pool)); +app.use('/api', require('./routes/operations')(pool)); +app.use('/api', require('./routes/log')(pool)); + +app.get('/', (req, res) => res.send('pf_app running')); + +const port = process.env.PORT || 3010; +app.listen(port, '0.0.0.0', () => console.log(`pf_app started on port ${port}`)); diff --git a/setup_sql/01_schema.sql b/setup_sql/01_schema.sql new file mode 100644 index 0000000..66f3bf5 --- /dev/null +++ b/setup_sql/01_schema.sql @@ -0,0 +1,61 @@ +-- Pivot Forecast schema install +-- Run once against target database: psql -d -f setup_sql/01_schema.sql + +CREATE SCHEMA IF NOT EXISTS pf; + +CREATE TABLE IF NOT EXISTS pf.source ( + id serial PRIMARY KEY, + schema text NOT NULL, + tname text NOT NULL, + label text, + status text NOT NULL DEFAULT 'active', -- active | archived + created_at timestamptz NOT NULL DEFAULT now(), + created_by text, + UNIQUE (schema, tname) +); + +CREATE TABLE IF NOT EXISTS pf.col_meta ( + id serial PRIMARY KEY, + source_id integer NOT NULL REFERENCES pf.source(id) ON DELETE CASCADE, + cname text NOT NULL, + label text, + role text NOT NULL DEFAULT 'ignore', -- dimension | value | units | date | ignore + is_key boolean NOT NULL DEFAULT false, -- true = usable in WHERE slice + opos integer, + UNIQUE (source_id, cname) +); + +CREATE TABLE IF NOT EXISTS pf.version ( + id serial PRIMARY KEY, + source_id integer NOT NULL REFERENCES pf.source(id) ON DELETE RESTRICT, + name text NOT NULL, + description text, + status text NOT NULL DEFAULT 'open', -- open | closed + exclude_iters jsonb NOT NULL DEFAULT '["reference"]'::jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + created_by text, + closed_at timestamptz, + closed_by text, + UNIQUE (source_id, name) +); + +CREATE TABLE IF NOT EXISTS pf.log ( + id bigserial PRIMARY KEY, + version_id integer NOT NULL REFERENCES pf.version(id) ON DELETE CASCADE, + pf_user text NOT NULL, + stamp timestamptz NOT NULL DEFAULT now(), + operation text NOT NULL, -- baseline | reference | scale | recode | clone + slice jsonb, + params jsonb, + note text +); + +-- generated operation SQL per source, stored after col_meta is configured +CREATE TABLE IF NOT EXISTS pf.sql ( + id serial PRIMARY KEY, + source_id integer NOT NULL REFERENCES pf.source(id) ON DELETE CASCADE, + operation text NOT NULL, -- get_data | baseline | reference | scale | recode | clone | undo + sql text NOT NULL, + generated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (source_id, operation) +);