diff --git a/server/.env.example b/server/.env.example deleted file mode 100644 index 6d69c15..0000000 --- a/server/.env.example +++ /dev/null @@ -1,4 +0,0 @@ -MONGODB_URI=mongodb+srv://username:password@cluster.example.mongodb.net/ -MONGODB_DATABASE=fatihsmg4 -API_TOKEN=replace-with-a-long-random-secret -PORT=8080 diff --git a/server/.gitignore b/server/.gitignore deleted file mode 100644 index 03c5ccf..0000000 --- a/server/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -node_modules/ -.env -npm-debug.log* diff --git a/server/Dockerfile b/server/Dockerfile deleted file mode 100644 index 0f5fb99..0000000 --- a/server/Dockerfile +++ /dev/null @@ -1,9 +0,0 @@ -FROM node:22-alpine -WORKDIR /app -COPY package*.json ./ -RUN npm ci --omit=dev -COPY src ./src -ENV NODE_ENV=production -USER node -EXPOSE 8080 -CMD ["node", "src/server.js"] diff --git a/server/README.md b/server/README.md deleted file mode 100644 index 24a56ef..0000000 --- a/server/README.md +++ /dev/null @@ -1,27 +0,0 @@ -# FatihsMG4 consumption API - -The Android app never connects to MongoDB directly and never contains a MongoDB URI. This -service stores completed daily totals in `daily_consumption` and each completed charge, -including its SOC/power curve and price, in `charging_sessions`. - -## Run - -1. Copy `.env.example` to `.env` and fill in the MongoDB Atlas URI and a random API token. -2. Export the variables (Node does not load `.env` automatically), then run `npm install` and - `npm start`. -3. Build the Android app with the public HTTPS service address and the same token: - - ```sh - CONSUMPTION_API_URL=https://your-api.example.com \ - CONSUMPTION_API_TOKEN=your-random-token \ - ./gradlew assembleUnstableRelease - ``` - -If either Android build variable is missing, cloud sync is disabled and local consumption -tracking continues unchanged. The car retries locally queued charging sessions every five minutes. -Daily consumption is uploaded once after the day closes; missed days are retried in order. - -The future mobile client can read authenticated history from: - -- `GET /v1/consumption/:installationId` -- `GET /v1/charging-sessions/:installationId` diff --git a/server/package-lock.json b/server/package-lock.json deleted file mode 100644 index 72f9a88..0000000 --- a/server/package-lock.json +++ /dev/null @@ -1,1040 +0,0 @@ -{ - "name": "fatihsmg4-consumption-api", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "fatihsmg4-consumption-api", - "version": "1.0.0", - "dependencies": { - "express": "^5.1.0", - "mongodb": "^6.20.0" - }, - "engines": { - "node": ">=22" - } - }, - "node_modules/@mongodb-js/saslprep": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.6.0.tgz", - "integrity": "sha512-19jGXfpctLDJUELLUZGJ5BllcLT7rHdF+gTTaN2jzqu0bGkUhpdcYtgK2Vux+g3zuBAa0TM/W666QMIjYhYujw==", - "license": "MIT", - "dependencies": { - "sparse-bitfield": "^3.0.3" - } - }, - "node_modules/@types/webidl-conversions": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", - "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==", - "license": "MIT" - }, - "node_modules/@types/whatwg-url": { - "version": "11.0.5", - "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz", - "integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==", - "license": "MIT", - "dependencies": { - "@types/webidl-conversions": "*" - } - }, - "node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "license": "MIT", - "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/body-parser": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", - "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", - "license": "MIT", - "dependencies": { - "bytes": "^3.1.2", - "content-type": "^2.0.0", - "debug": "^4.4.3", - "http-errors": "^2.0.1", - "iconv-lite": "^0.7.2", - "on-finished": "^2.4.1", - "qs": "^6.15.2", - "raw-body": "^3.0.2", - "type-is": "^2.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/body-parser/node_modules/content-type": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", - "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/bson": { - "version": "6.10.4", - "resolved": "https://registry.npmjs.org/bson/-/bson-6.10.4.tgz", - "integrity": "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==", - "license": "Apache-2.0", - "engines": { - "node": ">=16.20.1" - } - }, - "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/content-disposition": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", - "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "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.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "license": "MIT", - "engines": { - "node": ">=6.6.0" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "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/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.2", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", - "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", - "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": "5.2.1", - "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", - "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", - "license": "MIT", - "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.1", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "depd": "^2.0.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/finalhandler": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", - "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "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": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "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/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-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.4", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", - "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", - "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.7.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", - "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "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-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "license": "MIT" - }, - "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": "1.1.1", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", - "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/memory-pager": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", - "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", - "license": "MIT" - }, - "node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/mongodb": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.21.0.tgz", - "integrity": "sha512-URyb/VXMjJ4da46OeSXg+puO39XH9DeQpWCslifrRn9JWugy0D+DvvBvkm2WxmHe61O/H19JM66p1z7RHVkZ6A==", - "license": "Apache-2.0", - "dependencies": { - "@mongodb-js/saslprep": "^1.3.0", - "bson": "^6.10.4", - "mongodb-connection-string-url": "^3.0.2" - }, - "engines": { - "node": ">=16.20.1" - }, - "peerDependencies": { - "@aws-sdk/credential-providers": "^3.188.0", - "@mongodb-js/zstd": "^1.1.0 || ^2.0.0", - "gcp-metadata": "^5.2.0", - "kerberos": "^2.0.1", - "mongodb-client-encryption": ">=6.0.0 <7", - "snappy": "^7.3.2", - "socks": "^2.7.1" - }, - "peerDependenciesMeta": { - "@aws-sdk/credential-providers": { - "optional": true - }, - "@mongodb-js/zstd": { - "optional": true - }, - "gcp-metadata": { - "optional": true - }, - "kerberos": { - "optional": true - }, - "mongodb-client-encryption": { - "optional": true - }, - "snappy": { - "optional": true - }, - "socks": { - "optional": true - } - } - }, - "node_modules/mongodb-connection-string-url": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.2.tgz", - "integrity": "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==", - "license": "Apache-2.0", - "dependencies": { - "@types/whatwg-url": "^11.0.2", - "whatwg-url": "^14.1.0 || ^13.0.0" - } - }, - "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/negotiator": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", - "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", - "license": "MIT", - "dependencies": { - "content-type": "^2.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/negotiator/node_modules/content-type": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", - "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "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/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "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": "8.4.2", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", - "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/proxy-addr": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", - "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/qs": { - "version": "6.16.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", - "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", - "license": "BSD-3-Clause", - "dependencies": { - "es-define-property": "^1.0.1", - "side-channel": "^1.1.1" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/range-parser": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", - "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/raw-body": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", - "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.7.0", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/router": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", - "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "depd": "^2.0.0", - "is-promise": "^4.0.0", - "parseurl": "^1.3.3", - "path-to-regexp": "^8.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "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/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", - "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.3", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.1", - "mime-types": "^3.0.2", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/serve-static": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", - "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", - "license": "MIT", - "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "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.1", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", - "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.4", - "side-channel-list": "^1.0.1", - "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.1", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", - "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.4" - }, - "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/sparse-bitfield": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", - "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", - "license": "MIT", - "dependencies": { - "memory-pager": "^1.0.2" - } - }, - "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/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/tr46": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", - "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", - "license": "MIT", - "dependencies": { - "punycode": "^2.3.1" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/type-is": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", - "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", - "license": "MIT", - "dependencies": { - "content-type": "^2.0.0", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/type-is/node_modules/content-type": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", - "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "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/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/webidl-conversions": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", - "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - } - }, - "node_modules/whatwg-url": { - "version": "14.2.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", - "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", - "license": "MIT", - "dependencies": { - "tr46": "^5.1.0", - "webidl-conversions": "^7.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC" - } - } -} diff --git a/server/package.json b/server/package.json deleted file mode 100644 index 8ec4047..0000000 --- a/server/package.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "name": "fatihsmg4-consumption-api", - "version": "1.0.0", - "private": true, - "type": "module", - "scripts": { - "start": "node src/server.js", - "test": "node --test" - }, - "engines": { - "node": ">=22" - }, - "dependencies": { - "express": "^5.1.0", - "mongodb": "^6.20.0" - } -} diff --git a/server/src/app.js b/server/src/app.js deleted file mode 100644 index e280f4f..0000000 --- a/server/src/app.js +++ /dev/null @@ -1,130 +0,0 @@ -import crypto from "node:crypto"; -import express from "express"; - -export function createApp({ consumptionCollection, chargingCollection, apiToken }) { - if (!consumptionCollection || !chargingCollection) throw new Error("collections are required"); - if (!apiToken || apiToken.length < 24) throw new Error("API_TOKEN must contain at least 24 characters"); - const app = express(); - app.disable("x-powered-by"); - app.use(express.json({ limit: "256kb", strict: true })); - const auth = authenticate(apiToken); - app.get("/health", (_request, response) => response.json({ status: "ok" })); - - app.put("/v1/consumption/daily", auth, route(async (request, response) => { - const record = validateConsumption(request.body); - const updatedAt = new Date(); - await consumptionCollection.updateOne( - { installationId: record.installationId, date: record.date }, - { $set: { ...record, updatedAt }, $setOnInsert: { createdAt: updatedAt } }, { upsert: true }); - response.status(204).end(); - })); - - app.put("/v1/charging-sessions/:sessionId", auth, route(async (request, response) => { - const record = validateChargingSession(request.body, request.params.sessionId); - const updatedAt = new Date(); - await chargingCollection.updateOne( - { installationId: record.installationId, sessionId: record.sessionId }, - { $set: { ...record, updatedAt }, $setOnInsert: { createdAt: updatedAt } }, { upsert: true }); - response.status(204).end(); - })); - - app.get("/v1/consumption/:installationId", auth, route(async (request, response) => { - const installationId = validateUuid(request.params.installationId, "installationId"); - const records = await consumptionCollection.find({ installationId }).sort({ date: -1 }).limit(400).toArray(); - response.json({ records }); - })); - app.get("/v1/charging-sessions/:installationId", auth, route(async (request, response) => { - const installationId = validateUuid(request.params.installationId, "installationId"); - const records = await chargingCollection.find({ installationId }).sort({ startedAt: -1 }).limit(200).toArray(); - response.json({ records }); - })); - - app.use((error, _request, response, _next) => { - if (error instanceof ValidationError || error?.type === "entity.parse.failed" || error?.type === "entity.too.large") { - response.status(400).json({ error: error.message || "invalid request" }); - return; - } - console.error("Request failed", error); - response.status(500).json({ error: "internal server error" }); - }); - return app; -} - -function route(handler) { return (request, response, next) => Promise.resolve(handler(request, response)).catch(next); } - -function authenticate(expected) { - const expectedBytes = Buffer.from(expected); - return (request, response, next) => { - const header = request.get("authorization") ?? ""; - const supplied = header.startsWith("Bearer ") ? header.slice(7) : ""; - const suppliedBytes = Buffer.from(supplied); - if (suppliedBytes.length !== expectedBytes.length || !crypto.timingSafeEqual(suppliedBytes, expectedBytes)) { - response.status(401).json({ error: "unauthorized" }); return; - } - next(); - }; -} - -class ValidationError extends Error {} - -function validateConsumption(body) { - validateBody(body); - if (!/^\d{4}-\d{2}-\d{2}$/.test(body.date ?? "")) throw new ValidationError("date is invalid"); - return { - installationId: validateUuid(body.installationId, "installationId"), date: body.date, - recordedAt: validateDate(body.recordedAt, "recordedAt"), day: validateTotals(body.day, "day"), - lifetime: validateTotals(body.lifetime, "lifetime"), - ...(body.sohPercent === undefined ? {} : { sohPercent: bounded(body.sohPercent, 0, 100, "sohPercent") }) - }; -} - -function validateChargingSession(body, pathSessionId) { - validateBody(body); - const sessionId = validateUuid(body.sessionId, "sessionId"); - if (sessionId !== pathSessionId) throw new ValidationError("sessionId does not match path"); - if (!Array.isArray(body.curve) || body.curve.length > 720) throw new ValidationError("curve is invalid"); - const curve = body.curve.map((point) => ({ - timestamp: validateDate(point.timestampMs, "curve.timestampMs"), - powerKw: bounded(point.powerKw, 0, 1000, "curve.powerKw"), - ...(point.socPercent === undefined ? {} : { socPercent: bounded(point.socPercent, 0, 100, "curve.socPercent") }) - })); - return { - installationId: validateUuid(body.installationId, "installationId"), sessionId, - startedAt: validateDate(body.startedAt, "startedAt"), endedAt: validateDate(body.endedAt, "endedAt"), - durationSeconds: bounded(body.durationSeconds, 0, 604800, "durationSeconds"), - energyKwh: bounded(body.energyKwh, 0, 1000, "energyKwh"), - pricePerKwh: bounded(body.pricePerKwh, 0, 1_000_000, "pricePerKwh"), - totalCost: bounded(body.totalCost, 0, 1_000_000_000, "totalCost"), - ...(body.startSocPercent === undefined ? {} : { startSocPercent: bounded(body.startSocPercent, 0, 100, "startSocPercent") }), - ...(body.endSocPercent === undefined ? {} : { endSocPercent: bounded(body.endSocPercent, 0, 100, "endSocPercent") }), curve - }; -} - -function validateBody(body) { - if (!body || typeof body !== "object" || Array.isArray(body)) throw new ValidationError("body is required"); -} -function validateUuid(value, name) { - if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value ?? "")) - throw new ValidationError(`${name} is invalid`); - return value; -} -function validateDate(value, name) { - if (!Number.isSafeInteger(value) || value < 0) throw new ValidationError(`${name} is invalid`); - return new Date(value); -} -function bounded(value, min, max, name) { - if (typeof value !== "number" || !Number.isFinite(value) || value < min || value > max) - throw new ValidationError(`${name} is invalid`); - return value; -} -function validateTotals(value, name) { - if (!value || typeof value !== "object" || Array.isArray(value)) throw new ValidationError(`${name} is invalid`); - const result = {}; - for (const field of ["km", "kwh", "hours", "socDrop"]) { - const number = value[field]; - if (typeof number !== "number" || !Number.isFinite(number) || Math.abs(number) > 10_000_000) - throw new ValidationError(`${name}.${field} is invalid`); - result[field] = number; - } - return result; -} diff --git a/server/src/server.js b/server/src/server.js deleted file mode 100644 index 2beff3c..0000000 --- a/server/src/server.js +++ /dev/null @@ -1,32 +0,0 @@ -import { MongoClient } from "mongodb"; -import { createApp } from "./app.js"; - -const { MONGODB_URI, API_TOKEN } = process.env; -const databaseName = process.env.MONGODB_DATABASE || "fatihsmg4"; -const port = Number(process.env.PORT || 8080); - -if (!MONGODB_URI) throw new Error("MONGODB_URI is required"); -if (!API_TOKEN) throw new Error("API_TOKEN is required"); -if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("PORT is invalid"); - -const client = new MongoClient(MONGODB_URI, { serverSelectionTimeoutMS: 10_000 }); -await client.connect(); -const database = client.db(databaseName); -const consumptionCollection = database.collection("daily_consumption"); -const chargingCollection = database.collection("charging_sessions"); -await consumptionCollection.createIndex({ installationId: 1, date: 1 }, { unique: true }); -await consumptionCollection.createIndex({ installationId: 1, date: -1 }); -await chargingCollection.createIndex({ installationId: 1, sessionId: 1 }, { unique: true }); -await chargingCollection.createIndex({ installationId: 1, startedAt: -1 }); - -const app = createApp({ consumptionCollection, chargingCollection, apiToken: API_TOKEN }); -const server = app.listen(port, () => console.log(`FatihsMG4 API listening on ${port}`)); - -async function shutdown() { - server.close(async () => { - await client.close(); - process.exit(0); - }); -} -process.on("SIGINT", shutdown); -process.on("SIGTERM", shutdown); diff --git a/server/test/app.test.js b/server/test/app.test.js deleted file mode 100644 index dd6014b..0000000 --- a/server/test/app.test.js +++ /dev/null @@ -1,57 +0,0 @@ -import assert from "node:assert/strict"; -import { after, before, test } from "node:test"; -import { createApp } from "../src/app.js"; - -const token = "a-secure-test-token-with-24-chars"; -const consumptionWrites = [], chargingWrites = []; -const readable = (writes) => ({ - async updateOne(filter, update, options) { writes.push({ filter, update, options }); }, - find() { return { sort() { return this; }, limit() { return this; }, async toArray() { return []; } }; } -}); -let server, baseUrl; - -before(async () => { - const app = createApp({ consumptionCollection: readable(consumptionWrites), - chargingCollection: readable(chargingWrites), apiToken: token }); - await new Promise((resolve) => { server = app.listen(0, "127.0.0.1", resolve); }); - baseUrl = `http://127.0.0.1:${server.address().port}`; -}); -after(() => new Promise((resolve) => server.close(resolve))); - -const auth = { "content-type": "application/json", authorization: `Bearer ${token}` }; -const installationId = "123e4567-e89b-42d3-a456-426614174000"; -const consumption = { installationId, recordedAt: 1791417600000, date: "2026-10-08", - day: { km: 12, kwh: 2.1, hours: .4, socDrop: 4 }, - lifetime: { km: 1000, kwh: 170, hours: 30, socDrop: 400 }, sohPercent: 91.5 }; - -test("rejects requests without the API token", async () => { - const response = await fetch(`${baseUrl}/v1/consumption/daily`, { method: "PUT", body: JSON.stringify(consumption), headers: { "content-type": "application/json" } }); - assert.equal(response.status, 401); -}); - -test("upserts one daily consumption record", async () => { - const response = await fetch(`${baseUrl}/v1/consumption/daily`, { method: "PUT", headers: auth, body: JSON.stringify(consumption) }); - assert.equal(response.status, 204); - assert.deepEqual(consumptionWrites.at(-1).filter, { installationId, date: "2026-10-08" }); - assert.equal(consumptionWrites.at(-1).update.$set.day.kwh, 2.1); -}); - -test("upserts a complete charging session with its curve and price", async () => { - const sessionId = "223e4567-e89b-42d3-a456-426614174001"; - const session = { installationId, sessionId, startedAt: 1791417600000, endedAt: 1791421200000, - durationSeconds: 3600, energyKwh: 50, pricePerKwh: 8.5, totalCost: 425, - startSocPercent: 10, endSocPercent: 90, - curve: [{ timestampMs: 1791417600000, socPercent: 10, powerKw: 72 }] }; - const response = await fetch(`${baseUrl}/v1/charging-sessions/${sessionId}`, - { method: "PUT", headers: auth, body: JSON.stringify(session) }); - assert.equal(response.status, 204); - assert.deepEqual(chargingWrites.at(-1).filter, { installationId, sessionId }); - assert.equal(chargingWrites.at(-1).update.$set.totalCost, 425); - assert.equal(chargingWrites.at(-1).update.$set.curve[0].powerKw, 72); -}); - -test("read APIs are authenticated and mobile-client ready", async () => { - const response = await fetch(`${baseUrl}/v1/charging-sessions/${installationId}`, { headers: { authorization: `Bearer ${token}` } }); - assert.equal(response.status, 200); - assert.deepEqual(await response.json(), { records: [] }); -}); diff --git a/web/.env.example b/web/.env.example new file mode 100644 index 0000000..9d0ba74 --- /dev/null +++ b/web/.env.example @@ -0,0 +1,14 @@ +# MongoDB Atlas connection string. Never leaves the server. +MONGODB_URI=mongodb+srv://user:password@cluster.mongodb.net/?retryWrites=true&w=majority +MONGODB_DATABASE=fatihsmg4 + +# Write-only token the car sends as `Authorization: Bearer ...` on the PUT endpoints. +# Generate with: openssl rand -hex 32 +INGEST_TOKEN= + +# Password for the web dashboard. Anyone who knows it can read the history. +DASHBOARD_PASSWORD= + +# Signs the session cookie. Rotating it logs every browser out. +# Generate with: openssl rand -hex 32 +SESSION_SECRET= diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..8869056 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,6 @@ +node_modules +.next +.env +.env.local +next-env.d.ts +*.tsbuildinfo diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..9715a88 --- /dev/null +++ b/web/README.md @@ -0,0 +1,68 @@ +# FatihsMG4 web + +Next.js app on Vercel: the ingest API the car writes to, and a phone-shaped dashboard for +reading the history back. Replaces the standalone Express service that used to live in +`server/`. + +**The car never connects to MongoDB and never holds a MongoDB URI.** The URI is a Vercel +environment variable and is only ever read by a server-side route. + +## Two credentials, on purpose + +| | Who holds it | What it opens | +|---|---|---| +| `INGEST_TOKEN` | the car, in the APK | the `PUT` endpoints — write only | +| `DASHBOARD_PASSWORD` | you, in a browser | the dashboard and the `GET` history | + +The APK is published, so its token must be assumed extractable: anyone can `unzip` an APK +and read its strings. That is survivable here because the write token cannot read anything +back. The password is never compiled into anything. + +## Environment variables + +Set all five in Vercel → Settings → Environment Variables, for Production and Preview. + +| Name | Notes | +|---|---| +| `MONGODB_URI` | Atlas connection string | +| `MONGODB_DATABASE` | defaults to `fatihsmg4` | +| `INGEST_TOKEN` | `openssl rand -hex 32` | +| `DASHBOARD_PASSWORD` | at least 8 characters, or login is refused outright | +| `SESSION_SECRET` | `openssl rand -hex 32`; rotating it signs every browser out | + +Atlas network access must allow Vercel's egress, which is not a fixed address on the Hobby +plan — either allow `0.0.0.0/0` and rely on the database user's credentials, or use Atlas +private networking on a paid plan. + +## Endpoints + +Write, `Authorization: Bearer `: + +- `PUT /v1/consumption/daily` +- `PUT /v1/charging-sessions/:sessionId` + +Read, session cookie or `Authorization: Bearer `: + +- `GET /v1/history/:installationId/consumption` +- `GET /v1/history/:installationId/charging` + +`GET /v1/health` is unauthenticated and returns `{"status":"ok"}`. + +The car builds its URLs as `CONSUMPTION_API_URL + /v1/...`, so the Android build variable +is the bare origin — `https://your-app.vercel.app`, no trailing path. + +## Indexes + +There is no boot step on Vercel, so `ensureIndexes()` runs on the first write of each warm +lambda and is skipped afterwards. The unique indexes on `(installationId, date)` and +`(installationId, sessionId)` are what make the upserts idempotent when the car retries a +record it could not confirm. + +## Local development + +```sh +cp .env.example .env.local # Next loads .env.local automatically +npm install +npm run dev +npm test # pure validation and session-signing tests, no database +``` diff --git a/web/next.config.mjs b/web/next.config.mjs new file mode 100644 index 0000000..1f0ce49 --- /dev/null +++ b/web/next.config.mjs @@ -0,0 +1,6 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + // Every page reads live telemetry; a cached shell would show yesterday's drive. + experimental: { serverActions: { bodySizeLimit: "1mb" } }, +}; +export default nextConfig; diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..d4aa3dd --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,1145 @@ +{ + "name": "fatihsmg4-web", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "fatihsmg4-web", + "version": "1.0.0", + "dependencies": { + "mongodb": "^6.20.0", + "next": "^15.5.4", + "react": "^19.1.0", + "react-dom": "^19.1.0" + }, + "devDependencies": { + "@types/node": "^22.15.3", + "@types/react": "^19.1.2", + "typescript": "^5.8.3" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz", + "integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz", + "integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz", + "integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz", + "integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz", + "integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz", + "integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz", + "integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz", + "integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz", + "integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz", + "integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz", + "integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz", + "integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz", + "integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz", + "integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz", + "integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz", + "integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz", + "integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz", + "integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz", + "integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz", + "integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz", + "integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz", + "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz", + "integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz", + "integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz", + "integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz", + "integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@mongodb-js/saslprep": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.6.0.tgz", + "integrity": "sha512-19jGXfpctLDJUELLUZGJ5BllcLT7rHdF+gTTaN2jzqu0bGkUhpdcYtgK2Vux+g3zuBAa0TM/W666QMIjYhYujw==", + "license": "MIT", + "dependencies": { + "sparse-bitfield": "^3.0.3" + } + }, + "node_modules/@next/env": { + "version": "15.5.27", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.27.tgz", + "integrity": "sha512-WC5hvVqiuFOcRyz6gKPeJkiVvSMf3scNeAV49CdWtVqxmUlzh8UKR6YHt9yiNkh8LngtzqSlVz07/Ma3ZAzchA==", + "license": "MIT" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "15.5.27", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.27.tgz", + "integrity": "sha512-fBA9QurmK4+uj4wNhmRTuO5zMVRaBHCvp6H93zTG/5i6Yi6TewbvbesXB8YQNj8T99Cwrow87hHiFV6wL/hyMA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "15.5.27", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.27.tgz", + "integrity": "sha512-FGZwJwry8qZNHxD/nfwrCwCHHrqx6epqItkkxbYcBQEQ2uw5S43bY83fQB5zbAsZE7TELMjoYkbhsQ4oKM7wwg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "15.5.27", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.27.tgz", + "integrity": "sha512-GQulAaJYNF1RYuatlsw9/uzbKz4if2V7Ic9nVO+P0ljYPm4yBRTFIl6g5YkKTw40lMLbpy9edLoH44g3jSHMYA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "15.5.27", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.27.tgz", + "integrity": "sha512-URFFsUmu8Pi0/1qdnUI7tx7keZw1Mt5zHfrIX1RfDG9Nih8DYxK54WZsDJUX2ZTlii1jgotL7qcbwKnSAES6xQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "15.5.27", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.27.tgz", + "integrity": "sha512-aJp74z7uvU5jQhyH7lyk+4qSRcTdKbKT9NQE3jiO9kWYkoHZjKi0zzHDbCoZ+v2sJDEiJ/z58R0UlWMGWNF3HA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "15.5.27", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.27.tgz", + "integrity": "sha512-ILl2bQ8MbpvyMpY8AXljb3BXs0MJSC8HXZ6QV5AWpVS7ZELqNPtrHvhUk+SNarvjvICXX8bCS287TCi+gUFVYg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "15.5.27", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.27.tgz", + "integrity": "sha512-1MB9Rh5y8TOPN2x7naNTJNkCZQav9c70AI7EBYXxsEewwOffC4DjjwBwKf20NBvcA+u+5jDv7nVmHxunlP2d7Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "15.5.27", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.27.tgz", + "integrity": "sha512-XtYgpGOswgnyBWXzOEG6q9RhvnSw0CEZ0MbWqCsl5PISgt9EzvJvNW5mU5n1PsphX/lnamzxjgQXH2GUJL8nqg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@types/node": { + "version": "22.20.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz", + "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/webidl-conversions": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", + "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==", + "license": "MIT" + }, + "node_modules/@types/whatwg-url": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz", + "integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==", + "license": "MIT", + "dependencies": { + "@types/webidl-conversions": "*" + } + }, + "node_modules/bson": { + "version": "6.10.4", + "resolved": "https://registry.npmjs.org/bson/-/bson-6.10.4.tgz", + "integrity": "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==", + "license": "Apache-2.0", + "engines": { + "node": ">=16.20.1" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001815", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001815.tgz", + "integrity": "sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/memory-pager": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", + "license": "MIT" + }, + "node_modules/mongodb": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.21.0.tgz", + "integrity": "sha512-URyb/VXMjJ4da46OeSXg+puO39XH9DeQpWCslifrRn9JWugy0D+DvvBvkm2WxmHe61O/H19JM66p1z7RHVkZ6A==", + "license": "Apache-2.0", + "dependencies": { + "@mongodb-js/saslprep": "^1.3.0", + "bson": "^6.10.4", + "mongodb-connection-string-url": "^3.0.2" + }, + "engines": { + "node": ">=16.20.1" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.188.0", + "@mongodb-js/zstd": "^1.1.0 || ^2.0.0", + "gcp-metadata": "^5.2.0", + "kerberos": "^2.0.1", + "mongodb-client-encryption": ">=6.0.0 <7", + "snappy": "^7.3.2", + "socks": "^2.7.1" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, + "node_modules/mongodb-connection-string-url": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.2.tgz", + "integrity": "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==", + "license": "Apache-2.0", + "dependencies": { + "@types/whatwg-url": "^11.0.2", + "whatwg-url": "^14.1.0 || ^13.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/next": { + "version": "15.5.27", + "resolved": "https://registry.npmjs.org/next/-/next-15.5.27.tgz", + "integrity": "sha512-F82CrlPZ8GRxBH9RIIoR3qU1t5RT3dv1K8moe8SjlDEquiFipvEI13R1Om90TaUOEwShkWCMlg8+gvTNzfMhvg==", + "license": "MIT", + "dependencies": { + "@next/env": "15.5.27", + "@swc/helpers": "0.5.15", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "15.5.27", + "@next/swc-darwin-x64": "15.5.27", + "@next/swc-linux-arm64-gnu": "15.5.27", + "@next/swc-linux-arm64-musl": "15.5.27", + "@next/swc-linux-x64-gnu": "15.5.27", + "@next/swc-linux-x64-musl": "15.5.27", + "@next/swc-win32-arm64-msvc": "15.5.27", + "@next/swc-win32-x64-msvc": "15.5.27", + "sharp": "^0.34.3 || ^0.35.4" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz", + "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.5", + "@img/sharp-darwin-x64": "0.35.5", + "@img/sharp-freebsd-wasm32": "0.35.5", + "@img/sharp-libvips-darwin-arm64": "1.3.4", + "@img/sharp-libvips-darwin-x64": "1.3.4", + "@img/sharp-libvips-linux-arm": "1.3.4", + "@img/sharp-libvips-linux-arm64": "1.3.4", + "@img/sharp-libvips-linux-ppc64": "1.3.4", + "@img/sharp-libvips-linux-riscv64": "1.3.4", + "@img/sharp-libvips-linux-s390x": "1.3.4", + "@img/sharp-libvips-linux-x64": "1.3.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", + "@img/sharp-libvips-linuxmusl-x64": "1.3.4", + "@img/sharp-linux-arm": "0.35.5", + "@img/sharp-linux-arm64": "0.35.5", + "@img/sharp-linux-ppc64": "0.35.5", + "@img/sharp-linux-riscv64": "0.35.5", + "@img/sharp-linux-s390x": "0.35.5", + "@img/sharp-linux-x64": "0.35.5", + "@img/sharp-linuxmusl-arm64": "0.35.5", + "@img/sharp-linuxmusl-x64": "0.35.5", + "@img/sharp-webcontainers-wasm32": "0.35.5", + "@img/sharp-win32-arm64": "0.35.5", + "@img/sharp-win32-ia32": "0.35.5", + "@img/sharp-win32-x64": "0.35.5" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sparse-bitfield": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", + "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", + "license": "MIT", + "dependencies": { + "memory-pager": "^1.0.2" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..0cf8888 --- /dev/null +++ b/web/package.json @@ -0,0 +1,27 @@ +{ + "name": "fatihsmg4-web", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint", + "test": "node --test --experimental-strip-types test/*.test.ts" + }, + "engines": { + "node": ">=22" + }, + "dependencies": { + "mongodb": "^6.20.0", + "next": "^15.5.4", + "react": "^19.1.0", + "react-dom": "^19.1.0" + }, + "devDependencies": { + "@types/node": "^22.15.3", + "@types/react": "^19.1.2", + "typescript": "^5.8.3" + }, + "type": "module" +} \ No newline at end of file diff --git a/web/src/app/api/session/route.ts b/web/src/app/api/session/route.ts new file mode 100644 index 0000000..80f9df4 --- /dev/null +++ b/web/src/app/api/session/route.ts @@ -0,0 +1,23 @@ +import { NextResponse } from "next/server"; +import { cookies } from "next/headers"; +import { SESSION_COOKIE, checkDashboardPassword, issueSessionValue, sessionCookieOptions } from "@/lib/auth"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +/** + * Deliberately slow to fail. The password is the only thing between the open internet and + * the history, and a quarter second is unnoticeable once but ruinous for anyone working + * through a list. + */ +export async function POST(request: Request) { + const form = await request.formData(); + const password = String(form.get("password") ?? ""); + if (!checkDashboardPassword(password)) { + await new Promise((resolve) => setTimeout(resolve, 250)); + return NextResponse.redirect(new URL("/login?error=1", request.url), { status: 303 }); + } + const store = await cookies(); + store.set(SESSION_COOKIE, issueSessionValue(), sessionCookieOptions); + return NextResponse.redirect(new URL("/", request.url), { status: 303 }); +} diff --git a/web/src/app/charging/page.tsx b/web/src/app/charging/page.tsx new file mode 100644 index 0000000..7a2d760 --- /dev/null +++ b/web/src/app/charging/page.tsx @@ -0,0 +1,97 @@ +import { requireSession } from "@/lib/auth"; +import { loadOverview, sumCharging } from "@/lib/data"; +import { dateTime, duration, money, num, precise } from "@/lib/format"; +import { ChargeCurveChart } from "@/components/charts"; +import { Row, Shell, Tile } from "@/components/shell"; + +export const dynamic = "force-dynamic"; + +export default async function ChargingPage() { + await requireSession(); + const { installationId, sessions } = await loadOverview(1, 200); + + if (!installationId || sessions.length === 0) { + return ( + +
+

+ Şarj kaydı yok. Yalnızca 10 kW üstü DC şarjlar ölçülür; AC şarj kaydedilmez. +

+
+
+ ); + } + + const total = sumCharging(sessions); + + return ( + +
+ + + 0 ? money(total.cost / total.energyKwh) : "—"} + note="kWh başına" + tone="teal" + /> + +
+ +
+

Seanslar

+
    + {sessions.map((session) => { + const gained = + session.startSocPercent !== undefined && session.endSocPercent !== undefined + ? session.endSocPercent - session.startSocPercent + : null; + const peak = session.curve.reduce((max, point) => Math.max(max, point.powerKw), 0); + const average = + session.durationSeconds > 0 ? (session.energyKwh * 3600) / session.durationSeconds : 0; + return ( +
  • +
    + +
    +
    {dateTime(session.startedAt)}
    +
    + {duration(session.durationSeconds)} + {gained !== null && ` · %${num(session.startSocPercent as number)} → %${num(session.endSocPercent as number)}`} +
    +
    +
    + {precise(session.energyKwh)} + kWh +
    {money(session.totalCost)}
    +
    +
    +
    + ({ + timestamp: new Date(point.timestamp).toISOString(), + powerKw: point.powerKw, + socPercent: point.socPercent, + }))} + /> +
    + + + + + + 0 ? `${num(peak)} kW` : "—"} /> + 0 ? `${num(average)} kW` : "—"} /> + + +
    +
    +
    +
  • + ); + })} +
+
+
+ ); +} diff --git a/web/src/app/consumption/page.tsx b/web/src/app/consumption/page.tsx new file mode 100644 index 0000000..922f94b --- /dev/null +++ b/web/src/app/consumption/page.tsx @@ -0,0 +1,98 @@ +import { requireSession } from "@/lib/auth"; +import { loadOverview, sumDays } from "@/lib/data"; +import { duration, efficiency, isoDate, num, precise } from "@/lib/format"; +import { DailyEnergyChart, EfficiencyChart } from "@/components/charts"; +import { Row, Shell, Tile } from "@/components/shell"; + +export const dynamic = "force-dynamic"; + +export default async function ConsumptionPage() { + await requireSession(); + const { installationId, days } = await loadOverview(90, 1); + + if (!installationId || days.length === 0) { + return ( + +
+

Günlük tüketim kaydı yok. Araç günü kapattığında yüklenir.

+
+
+ ); + } + + const chartDays = days.map((day) => ({ date: day.date, kwh: day.day.kwh, km: day.day.km })); + const total = sumDays(days); + const lifetime = days[days.length - 1].lifetime; + // Newest first for reading; the charts above keep chronological order. + const table = [...days].reverse(); + + return ( + +
+ + + + 0 ? num(total.km / total.hours) : "—"} + unit={total.hours > 0 ? "km/h" : undefined} + /> +
+ +
+

Günlük enerji

+ +
+ +
+

Verimlilik eğilimi

+ +
+ +
+

Ömür boyu

+
+ + + + +
+
+ +
+

Gün gün

+
    + {table.map((day) => ( +
  • +
    + +
    +
    {isoDate(day.date)}
    +
    + {num(day.day.km)} km · {efficiency(day.day.kwh, day.day.km)} +
    +
    +
    + {precise(day.day.kwh)} + kWh +
    +
    +
    +
    + + 0 ? `${num(day.day.km / day.day.hours)} km/h` : "—"} + /> + + +
    +
    +
    +
  • + ))} +
+
+
+ ); +} diff --git a/web/src/app/globals.css b/web/src/app/globals.css new file mode 100644 index 0000000..1391344 --- /dev/null +++ b/web/src/app/globals.css @@ -0,0 +1,363 @@ +/* + * The car's palette, from app/src/main/res/values/colors.xml. + * + * Permanent OLED black with charcoal cards and muted gold/cyan accents — the same visual + * direction AGENTS.md pins for the head unit, so the phone and the dash read as one app. + * No light mode: this is looked at in a dark car as often as anywhere else. + */ +:root { + --bg: #000000; + --surface: #121212; + --raised: #1b1d1e; + --outline: #363b3d; + --text: #f4f3ef; + --muted: #bdc4c6; + --accent: #d8c39a; + --on-accent: #17140e; + --cyan: #a0e2e0; + --cyan-deep: #103436; + --gold-surface: #28241b; + --ok: #a4dcc1; + --warn: #e9ca96; + --error: #ffb4ab; + + --radius: 18px; + --gap: 12px; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; + background: var(--bg); + color: var(--text); + font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + -webkit-font-smoothing: antialiased; + font-variant-numeric: tabular-nums; +} + +a { + color: inherit; + text-decoration: none; +} + +/* The phone frame. Wide windows get the same column rather than a stretched dashboard. */ +.shell { + max-width: 480px; + margin: 0 auto; + min-height: 100dvh; + padding: 20px 16px calc(84px + env(safe-area-inset-bottom)); + display: flex; + flex-direction: column; + gap: var(--gap); +} + +.header { + display: flex; + align-items: baseline; + justify-content: space-between; + margin-bottom: 4px; +} + +.title { + font-size: 26px; + font-weight: 650; + letter-spacing: -0.4px; + margin: 0; +} + +.subtitle { + color: var(--muted); + font-size: 13px; + margin: 2px 0 0; +} + +.card { + background: var(--surface); + border: 1px solid var(--outline); + border-radius: var(--radius); + padding: 16px; +} + +.card-title { + font-size: 12px; + letter-spacing: 1.1px; + text-transform: uppercase; + color: var(--muted); + margin: 0 0 12px; + font-weight: 600; +} + +/* Headline numbers: two per row, gold for energy/cost, cyan for distance/efficiency. */ +.tiles { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--gap); +} + +.tile { + background: var(--surface); + border: 1px solid var(--outline); + border-radius: var(--radius); + padding: 14px 16px; +} + +.tile.gold { + background: linear-gradient(160deg, var(--gold-surface), var(--surface) 70%); +} + +.tile.teal { + background: linear-gradient(160deg, var(--cyan-deep), var(--surface) 70%); +} + +.tile-label { + font-size: 11px; + letter-spacing: 0.8px; + text-transform: uppercase; + color: var(--muted); + margin: 0; +} + +.tile-value { + font-size: 24px; + font-weight: 650; + margin: 6px 0 0; + letter-spacing: -0.5px; +} + +.tile-unit { + font-size: 13px; + font-weight: 500; + color: var(--muted); + margin-left: 3px; +} + +.tile-note { + font-size: 11px; + color: var(--muted); + margin: 4px 0 0; +} + +/* Key/value rows inside a card. */ +.rows { + display: flex; + flex-direction: column; + gap: 10px; +} + +.row { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 12px; + font-size: 14px; +} + +.row + .row { + border-top: 1px solid var(--outline); + padding-top: 10px; +} + +.row-key { + color: var(--muted); +} + +.row-value { + font-weight: 600; +} + +.list { + display: flex; + flex-direction: column; + gap: 10px; + list-style: none; + margin: 0; + padding: 0; +} + +.session { + background: var(--surface); + border: 1px solid var(--outline); + border-radius: var(--radius); + padding: 14px 16px; +} + +.session summary { + cursor: pointer; + list-style: none; + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; +} + +.session summary::-webkit-details-marker { + display: none; +} + +.session-when { + font-size: 14px; + font-weight: 600; +} + +.session-meta { + font-size: 12px; + color: var(--muted); + margin-top: 3px; +} + +.session-energy { + text-align: right; + font-size: 18px; + font-weight: 650; + color: var(--accent); + white-space: nowrap; +} + +.session-cost { + font-size: 12px; + color: var(--muted); + font-weight: 500; +} + +.session-body { + margin-top: 14px; + padding-top: 14px; + border-top: 1px solid var(--outline); +} + +.chart { + width: 100%; + height: auto; + display: block; + overflow: visible; +} + +.legend { + display: flex; + gap: 14px; + font-size: 11px; + color: var(--muted); + margin-top: 8px; +} + +.swatch { + display: inline-block; + width: 9px; + height: 9px; + border-radius: 2px; + margin-right: 5px; + vertical-align: baseline; +} + +.empty { + color: var(--muted); + font-size: 14px; + text-align: center; + padding: 28px 12px; +} + +/* Bottom navigation — the car app falls back to the same pattern below 1000dp. */ +.nav { + position: fixed; + left: 0; + right: 0; + bottom: 0; + background: rgba(10, 10, 10, 0.92); + backdrop-filter: blur(14px); + border-top: 1px solid var(--outline); + padding: 8px 0 calc(8px + env(safe-area-inset-bottom)); +} + +.nav-inner { + max-width: 480px; + margin: 0 auto; + display: grid; + grid-template-columns: repeat(3, 1fr); +} + +.nav a { + text-align: center; + padding: 8px 4px; + font-size: 11px; + color: var(--muted); + font-weight: 600; + letter-spacing: 0.3px; +} + +.nav a[aria-current="page"] { + color: var(--accent); +} + +.nav-icon { + display: block; + font-size: 17px; + margin-bottom: 2px; +} + +/* Login */ +.login { + min-height: 100dvh; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; +} + +.login-card { + width: 100%; + max-width: 360px; + background: var(--surface); + border: 1px solid var(--outline); + border-radius: var(--radius); + padding: 28px 24px; +} + +.field { + width: 100%; + background: var(--raised); + border: 1px solid var(--outline); + border-radius: 12px; + color: var(--text); + padding: 13px 14px; + font-size: 16px; + margin-top: 16px; +} + +.field:focus { + outline: none; + border-color: var(--accent); +} + +.button { + width: 100%; + margin-top: 14px; + background: var(--accent); + color: var(--on-accent); + border: none; + border-radius: 12px; + padding: 13px; + font-size: 15px; + font-weight: 650; + cursor: pointer; +} + +.error { + color: var(--error); + font-size: 13px; + margin: 14px 0 0; +} + +.signout { + font-size: 12px; + color: var(--muted); + background: none; + border: 1px solid var(--outline); + border-radius: 999px; + padding: 5px 12px; + cursor: pointer; +} diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx new file mode 100644 index 0000000..a3a2aeb --- /dev/null +++ b/web/src/app/layout.tsx @@ -0,0 +1,23 @@ +import type { Metadata, Viewport } from "next"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "FatihsMG4", + description: "MG4 tüketim ve şarj geçmişi", +}; + +export const viewport: Viewport = { + themeColor: "#000000", + width: "device-width", + initialScale: 1, + // The dashboard is pinned to a phone-width column; zooming it is still the reader's call. + maximumScale: 5, +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/web/src/app/login/page.tsx b/web/src/app/login/page.tsx new file mode 100644 index 0000000..257f7b4 --- /dev/null +++ b/web/src/app/login/page.tsx @@ -0,0 +1,34 @@ +import { redirect } from "next/navigation"; +import { hasSession } from "@/lib/auth"; + +export const dynamic = "force-dynamic"; + +export default async function LoginPage({ + searchParams, +}: { + searchParams: Promise<{ error?: string }>; +}) { + if (await hasSession()) redirect("/"); + const { error } = await searchParams; + return ( +
+
+

FatihsMG4

+

Tüketim ve şarj geçmişi

+ + + {error &&

Parola hatalı.

} +
+
+ ); +} diff --git a/web/src/app/logout/route.ts b/web/src/app/logout/route.ts new file mode 100644 index 0000000..0418232 --- /dev/null +++ b/web/src/app/logout/route.ts @@ -0,0 +1,12 @@ +import { NextResponse } from "next/server"; +import { cookies } from "next/headers"; +import { SESSION_COOKIE } from "@/lib/auth"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET(request: Request) { + const store = await cookies(); + store.delete(SESSION_COOKIE); + return NextResponse.redirect(new URL("/login", request.url)); +} diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx new file mode 100644 index 0000000..3d4b312 --- /dev/null +++ b/web/src/app/page.tsx @@ -0,0 +1,98 @@ +import { requireSession } from "@/lib/auth"; +import { loadOverview, sumCharging, sumDays } from "@/lib/data"; +import { dateTime, duration, efficiency, money, num, precise } from "@/lib/format"; +import { DailyEnergyChart } from "@/components/charts"; +import { Row, Shell, Tile } from "@/components/shell"; + +export const dynamic = "force-dynamic"; + +export default async function OverviewPage() { + await requireSession(); + const { installationId, installationCount, days, sessions } = await loadOverview(30, 200); + + if (!installationId) { + return ( + +
+

+ Henüz veri yok. Araç ilk günlük kaydı gönderdiğinde burada görünecek. +

+
+
+ ); + } + + const charge = sumCharging(sessions); + const recent = sumDays(days); + const lifetime = days.length > 0 ? days[days.length - 1].lifetime : null; + const soh = [...days].reverse().find((day) => day.sohPercent !== undefined)?.sohPercent; + const lastSession = sessions[0]; + + return ( + 1 ? `${installationCount} araçtan en günceli` : "MG4"} + > +
+ + 0 ? `${money(charge.cost / charge.energyKwh)}/kWh ort.` : undefined} + tone="gold" + /> + + +
+ +
+

Son 30 gün

+ ({ date: day.date, kwh: day.day.kwh, km: day.day.km }))} /> +
+ + {lastSession && ( +
+

Son şarj

+
+ + + + + +
+
+ )} + +
+

Batarya ve toplamlar

+
+ + + 0 ? `${num(charge.peakKw)} kW` : "—"} /> + 0 ? `%${num(charge.socGained)}` : "—"} /> + +
+
+
+ ); +} diff --git a/web/src/app/v1/charging-sessions/[sessionId]/route.ts b/web/src/app/v1/charging-sessions/[sessionId]/route.ts new file mode 100644 index 0000000..d36ff88 --- /dev/null +++ b/web/src/app/v1/charging-sessions/[sessionId]/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from "next/server"; +import { handle, parseJson, requireIngest } from "@/lib/api"; +import { chargingCollection, ensureIndexes } from "@/lib/mongo"; +import { validateChargingSession } from "@/lib/validate"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function PUT(request: Request, context: { params: Promise<{ sessionId: string }> }) { + const denied = requireIngest(request); + if (denied) return denied; + return handle(async () => { + const { sessionId } = await context.params; + const record = validateChargingSession(await parseJson(request), sessionId); + await ensureIndexes(); + const collection = await chargingCollection(); + const updatedAt = new Date(); + await collection.updateOne( + { installationId: record.installationId, sessionId: record.sessionId }, + { $set: { ...record, updatedAt }, $setOnInsert: { createdAt: updatedAt } }, + { upsert: true }, + ); + return new NextResponse(null, { status: 204 }); + }); +} diff --git a/web/src/app/v1/consumption/daily/route.ts b/web/src/app/v1/consumption/daily/route.ts new file mode 100644 index 0000000..6c753ab --- /dev/null +++ b/web/src/app/v1/consumption/daily/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from "next/server"; +import { handle, parseJson, requireIngest } from "@/lib/api"; +import { consumptionCollection, ensureIndexes } from "@/lib/mongo"; +import { validateConsumption } from "@/lib/validate"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +/** + * Upsert on (installationId, date). The car retries a day it could not confirm, so the + * same day arrives more than once and must land on the same document rather than a + * duplicate — the unique index is what guarantees that under a concurrent retry. + */ +export async function PUT(request: Request) { + const denied = requireIngest(request); + if (denied) return denied; + return handle(async () => { + const record = validateConsumption(await parseJson(request)); + await ensureIndexes(); + const collection = await consumptionCollection(); + const updatedAt = new Date(); + await collection.updateOne( + { installationId: record.installationId, date: record.date }, + { $set: { ...record, updatedAt }, $setOnInsert: { createdAt: updatedAt } }, + { upsert: true }, + ); + return new NextResponse(null, { status: 204 }); + }); +} diff --git a/web/src/app/v1/health/route.ts b/web/src/app/v1/health/route.ts new file mode 100644 index 0000000..4efb85e --- /dev/null +++ b/web/src/app/v1/health/route.ts @@ -0,0 +1,9 @@ +import { NextResponse } from "next/server"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +/** Unauthenticated on purpose: it reveals nothing and the car has no other probe. */ +export function GET() { + return NextResponse.json({ status: "ok" }); +} diff --git a/web/src/app/v1/history/[installationId]/charging/route.ts b/web/src/app/v1/history/[installationId]/charging/route.ts new file mode 100644 index 0000000..73dd795 --- /dev/null +++ b/web/src/app/v1/history/[installationId]/charging/route.ts @@ -0,0 +1,22 @@ +import { NextResponse } from "next/server"; +import { handle, requireReader } from "@/lib/api"; +import { chargingCollection } from "@/lib/mongo"; +import { validateUuid } from "@/lib/validate"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET(request: Request, context: { params: Promise<{ installationId: string }> }) { + const denied = await requireReader(request); + if (denied) return denied; + return handle(async () => { + const { installationId } = await context.params; + const collection = await chargingCollection(); + const records = await collection + .find({ installationId: validateUuid(installationId, "installationId") }, { projection: { _id: 0 } }) + .sort({ startedAt: -1 }) + .limit(200) + .toArray(); + return NextResponse.json({ records }); + }); +} diff --git a/web/src/app/v1/history/[installationId]/consumption/route.ts b/web/src/app/v1/history/[installationId]/consumption/route.ts new file mode 100644 index 0000000..19d108c --- /dev/null +++ b/web/src/app/v1/history/[installationId]/consumption/route.ts @@ -0,0 +1,22 @@ +import { NextResponse } from "next/server"; +import { handle, requireReader } from "@/lib/api"; +import { consumptionCollection } from "@/lib/mongo"; +import { validateUuid } from "@/lib/validate"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET(request: Request, context: { params: Promise<{ installationId: string }> }) { + const denied = await requireReader(request); + if (denied) return denied; + return handle(async () => { + const { installationId } = await context.params; + const collection = await consumptionCollection(); + const records = await collection + .find({ installationId: validateUuid(installationId, "installationId") }, { projection: { _id: 0 } }) + .sort({ date: -1 }) + .limit(400) + .toArray(); + return NextResponse.json({ records }); + }); +} diff --git a/web/src/components/charts.tsx b/web/src/components/charts.tsx new file mode 100644 index 0000000..b02c6be --- /dev/null +++ b/web/src/components/charts.tsx @@ -0,0 +1,267 @@ +/* + * Hand-rolled SVG charts. + * + * A charting library would be the larger part of the bundle for three small plots, and + * these are server-rendered: no hydration, no client JavaScript, and they draw correctly + * before anything loads. Each one states its own units in the legend rather than relying + * on a tooltip, because a tooltip needs a pointer and this is read on a phone. + */ + +const GOLD = "#d8c39a"; +const CYAN = "#a0e2e0"; +const GRID = "#363b3d"; +const MUTED = "#bdc4c6"; + +interface Plot { + width: number; + height: number; + padTop: number; + padRight: number; + padBottom: number; + padLeft: number; +} + +const PLOT: Plot = { width: 320, height: 150, padTop: 10, padRight: 8, padBottom: 20, padLeft: 34 }; + +function innerWidth(plot: Plot) { + return plot.width - plot.padLeft - plot.padRight; +} +function innerHeight(plot: Plot) { + return plot.height - plot.padTop - plot.padBottom; +} + +/** A rounded axis maximum, so the gridline labels are readable numbers. */ +function niceMax(value: number): number { + if (!(value > 0)) return 1; + const magnitude = 10 ** Math.floor(Math.log10(value)); + const normalised = value / magnitude; + const step = normalised <= 1 ? 1 : normalised <= 2 ? 2 : normalised <= 5 ? 5 : 10; + return step * magnitude; +} + +function Grid({ plot, max, format }: { plot: Plot; max: number; format: (value: number) => string }) { + const lines = [0, 0.5, 1]; + return ( + + {lines.map((fraction) => { + const y = plot.padTop + innerHeight(plot) * (1 - fraction); + return ( + + + + {format(max * fraction)} + + + ); + })} + + ); +} + +export interface DayDatum { + date: string; + kwh: number; + km: number; +} + +/** + * Daily energy as bars. Days with no record are absent rather than drawn as zero — the + * car was not driven, which is not the same as a day of zero consumption, and the same + * distinction the telemetry side makes between a failed read and a zero. + */ +export function DailyEnergyChart({ days }: { days: DayDatum[] }) { + if (days.length === 0) return

Henüz günlük kayıt yok

; + const plot = PLOT; + const max = niceMax(Math.max(...days.map((day) => day.kwh))); + const slot = innerWidth(plot) / days.length; + const barWidth = Math.max(1.5, Math.min(14, slot * 0.62)); + return ( + <> + + value.toFixed(0)} /> + {days.map((day, index) => { + const height = (day.kwh / max) * innerHeight(plot); + const x = plot.padLeft + slot * (index + 0.5) - barWidth / 2; + return ( + 0 ? 1 : 0)} + rx={2} + fill={GOLD} + opacity={0.92} + > + {`${day.date} — ${day.kwh.toFixed(2)} kWh, ${day.km.toFixed(1)} km`} + + ); + })} + + {shortDate(days[0].date)} + + + {shortDate(days[days.length - 1].date)} + + +
+ + + Günlük enerji (kWh) + +
+ + ); +} + +/** Efficiency over time. Days under 1 km are dropped: the ratio is meaningless there. */ +export function EfficiencyChart({ days }: { days: DayDatum[] }) { + const points = days + .filter((day) => day.km >= 1 && day.kwh > 0) + .map((day) => ({ date: day.date, value: (day.kwh * 100) / day.km })); + if (points.length < 2) return

Grafik için yeterli gün yok

; + const plot = PLOT; + const max = niceMax(Math.max(...points.map((point) => point.value))); + const step = innerWidth(plot) / Math.max(1, points.length - 1); + const path = points + .map((point, index) => { + const x = plot.padLeft + step * index; + const y = plot.padTop + innerHeight(plot) * (1 - point.value / max); + return `${index === 0 ? "M" : "L"}${x.toFixed(2)} ${y.toFixed(2)}`; + }) + .join(" "); + const average = points.reduce((total, point) => total + point.value, 0) / points.length; + const averageY = plot.padTop + innerHeight(plot) * (1 - average / max); + return ( + <> + + value.toFixed(0)} /> + + + {points.map((point, index) => ( + + {`${point.date} — ${point.value.toFixed(1)} kWh/100 km`} + + ))} + +
+ + + kWh/100 km + + + + Ortalama {average.toFixed(1)} + +
+ + ); +} + +export interface CurveDatum { + timestamp: string; + powerKw: number; + socPercent?: number; +} + +/** + * Charge curve: power against time, with SOC on its own 0–100 scale. + * + * Two quantities on one plot because the shape of a DC charge is the relationship between + * them — the taper is only legible next to the percentage that caused it. + */ +export function ChargeCurveChart({ curve }: { curve: CurveDatum[] }) { + if (curve.length < 2) return

Bu seans için eğri kaydı yok

; + const plot = { ...PLOT, padRight: 26 }; + const times = curve.map((point) => new Date(point.timestamp).getTime()); + const start = times[0]; + const span = Math.max(1, times[times.length - 1] - start); + const maxPower = niceMax(Math.max(...curve.map((point) => point.powerKw))); + + const x = (time: number) => plot.padLeft + (innerWidth(plot) * (time - start)) / span; + const yPower = (kw: number) => plot.padTop + innerHeight(plot) * (1 - kw / maxPower); + const ySoc = (percent: number) => plot.padTop + innerHeight(plot) * (1 - percent / 100); + + const powerPath = curve + .map((point, index) => `${index === 0 ? "M" : "L"}${x(times[index]).toFixed(2)} ${yPower(point.powerKw).toFixed(2)}`) + .join(" "); + const areaPath = `${powerPath} L${x(times[times.length - 1]).toFixed(2)} ${(plot.padTop + innerHeight(plot)).toFixed(2)} L${x(start).toFixed(2)} ${(plot.padTop + innerHeight(plot)).toFixed(2)} Z`; + + const socPoints = curve + .map((point, index) => ({ point, index })) + .filter((entry) => typeof entry.point.socPercent === "number"); + const socPath = socPoints + .map((entry, order) => + `${order === 0 ? "M" : "L"}${x(times[entry.index]).toFixed(2)} ${ySoc(entry.point.socPercent as number).toFixed(2)}`) + .join(" "); + + return ( + <> + + value.toFixed(0)} /> + + + + + + + + + {socPath && } + + 100% + + + 0% + + + 0 dk + + + {Math.round(span / 60000)} dk + + +
+ + + Güç (kW) + + + + Doluluk (%) + +
+ + ); +} + +function shortDate(iso: string): string { + const [, month, day] = iso.split("-"); + return `${day}.${month}`; +} diff --git a/web/src/components/shell.tsx b/web/src/components/shell.tsx new file mode 100644 index 0000000..2467176 --- /dev/null +++ b/web/src/components/shell.tsx @@ -0,0 +1,91 @@ +import Link from "next/link"; +import type { ReactNode } from "react"; + +const TABS = [ + { href: "/", label: "Özet", icon: "◈" }, + { href: "/consumption", label: "Tüketim", icon: "▤" }, + { href: "/charging", label: "Şarj", icon: "⚡" }, +] as const; + +export type Tab = (typeof TABS)[number]["href"]; + +/** + * The phone frame and the bottom tab bar. + * + * `active` is passed in rather than read from the pathname so every page stays a server + * component — the whole dashboard renders without shipping a line of client JavaScript. + */ +export function Shell({ + active, + title, + subtitle, + children, +}: { + active: Tab; + title: string; + subtitle?: string; + children: ReactNode; +}) { + return ( + <> +
+
+
+

{title}

+ {subtitle &&

{subtitle}

} +
+ + Çıkış + +
+ {children} +
+ + + ); +} + +export function Tile({ + label, + value, + unit, + note, + tone, +}: { + label: string; + value: string; + unit?: string; + note?: string; + tone?: "gold" | "teal"; +}) { + return ( +
+

{label}

+

+ {value} + {unit && {unit}} +

+ {note &&

{note}

} +
+ ); +} + +export function Row({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts new file mode 100644 index 0000000..327e63b --- /dev/null +++ b/web/src/lib/api.ts @@ -0,0 +1,53 @@ +import { NextResponse } from "next/server"; +import { hasIngestToken, hasSession, checkDashboardPassword } from "./auth"; +import { ValidationError } from "./validate"; + +/** Every endpoint touches MongoDB, which rules out the edge runtime. */ +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export function unauthorized(): NextResponse { + return NextResponse.json({ error: "unauthorized" }, { status: 401 }); +} + +/** + * Wraps a handler so a validation failure is a 400 and anything else is a 500 whose + * detail stays in the server log. An error message is allowed to name a bad field; it is + * never allowed to carry a connection string. + */ +export async function handle(work: () => Promise): Promise { + try { + return await work(); + } catch (error) { + if (error instanceof ValidationError) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } + console.error("Request failed", error); + return NextResponse.json({ error: "internal server error" }, { status: 500 }); + } +} + +export function requireIngest(request: Request): NextResponse | null { + return hasIngestToken(request) ? null : unauthorized(); +} + +/** + * Read access: the dashboard's own session cookie, or the dashboard password as a bearer + * token so a native client can read the same history without a browser. + */ +export async function requireReader(request: Request): Promise { + if (await hasSession()) return null; + const header = request.headers.get("authorization") ?? ""; + if (header.startsWith("Bearer ") && checkDashboardPassword(header.slice(7))) return null; + return unauthorized(); +} + +export async function parseJson(request: Request): Promise { + const raw = await request.text(); + if (raw.length > 512 * 1024) throw new ValidationError("body is too large"); + try { + return JSON.parse(raw); + } catch { + throw new ValidationError("body is not valid JSON"); + } +} diff --git a/web/src/lib/auth.ts b/web/src/lib/auth.ts new file mode 100644 index 0000000..c1355b5 --- /dev/null +++ b/web/src/lib/auth.ts @@ -0,0 +1,20 @@ +/** + * The Next-aware half of authentication. Everything cryptographic lives in + * {@link ./session}, which imports nothing from Next and is therefore unit-testable + * without a request, a build, or a running server. + */ +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import { SESSION_COOKIE, isValidSessionValue } from "./session"; + +export * from "./session"; + +export async function hasSession(): Promise { + const store = await cookies(); + return isValidSessionValue(store.get(SESSION_COOKIE)?.value); +} + +/** For pages: send an unauthenticated visitor to the password screen. */ +export async function requireSession(): Promise { + if (!(await hasSession())) redirect("/login"); +} diff --git a/web/src/lib/data.ts b/web/src/lib/data.ts new file mode 100644 index 0000000..dcf0d02 --- /dev/null +++ b/web/src/lib/data.ts @@ -0,0 +1,84 @@ +import { chargingCollection, consumptionCollection, type ChargingSession, type DailyConsumption } from "./mongo"; + +/** + * Reads for the dashboard. + * + * Every query is scoped to one installation id. There is one car today, but the id is in + * the schema and in the unique indexes, so scoping now costs nothing and means a second + * head unit does not silently merge its history into the first one's charts. + */ + +export interface Overview { + installationId: string | null; + installationCount: number; + days: DailyConsumption[]; + sessions: ChargingSession[]; +} + +/** The installation that reported most recently — the car in use, if there are ever two. */ +export async function latestInstallationId(): Promise<{ id: string | null; count: number }> { + const collection = await consumptionCollection(); + const ids = await collection.distinct("installationId"); + if (ids.length === 0) { + const charging = await chargingCollection(); + const chargingIds = await charging.distinct("installationId"); + return { id: chargingIds[0] ?? null, count: chargingIds.length }; + } + if (ids.length === 1) return { id: ids[0], count: 1 }; + const newest = await collection.find({}, { projection: { installationId: 1 } }) + .sort({ date: -1 }).limit(1).next(); + return { id: newest?.installationId ?? ids[0], count: ids.length }; +} + +export async function loadOverview(dayLimit = 60, sessionLimit = 60): Promise { + const { id, count } = await latestInstallationId(); + if (!id) return { installationId: null, installationCount: 0, days: [], sessions: [] }; + const [consumption, charging] = await Promise.all([consumptionCollection(), chargingCollection()]); + const [days, sessions] = await Promise.all([ + consumption.find({ installationId: id }, { projection: { _id: 0 } }) + .sort({ date: -1 }).limit(dayLimit).toArray(), + charging.find({ installationId: id }, { projection: { _id: 0 } }) + .sort({ startedAt: -1 }).limit(sessionLimit).toArray(), + ]); + // Charts read left-to-right in time; the queries sort newest-first so the limit keeps + // the recent end rather than the beginning of history. + return { installationId: id, installationCount: count, days: days.reverse(), sessions }; +} + +export interface ChargeTotals { + energyKwh: number; + cost: number; + sessions: number; + seconds: number; + socGained: number; + peakKw: number; +} + +export function sumCharging(sessions: ChargingSession[]): ChargeTotals { + return sessions.reduce( + (total, session) => ({ + energyKwh: total.energyKwh + session.energyKwh, + cost: total.cost + session.totalCost, + sessions: total.sessions + 1, + seconds: total.seconds + session.durationSeconds, + socGained: + total.socGained + + (session.startSocPercent !== undefined && session.endSocPercent !== undefined + ? Math.max(0, session.endSocPercent - session.startSocPercent) + : 0), + peakKw: Math.max(total.peakKw, ...session.curve.map((point) => point.powerKw), 0), + }), + { energyKwh: 0, cost: 0, sessions: 0, seconds: 0, socGained: 0, peakKw: 0 }, + ); +} + +export function sumDays(days: DailyConsumption[]) { + return days.reduce( + (total, record) => ({ + km: total.km + record.day.km, + kwh: total.kwh + record.day.kwh, + hours: total.hours + record.day.hours, + }), + { km: 0, kwh: 0, hours: 0 }, + ); +} diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts new file mode 100644 index 0000000..19869fc --- /dev/null +++ b/web/src/lib/format.ts @@ -0,0 +1,48 @@ +/** Turkish formatting for every user-facing number and date. */ + +const NUMBER = new Intl.NumberFormat("tr-TR", { maximumFractionDigits: 1 }); +const PRECISE = new Intl.NumberFormat("tr-TR", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); +const CURRENCY = new Intl.NumberFormat("tr-TR", { style: "currency", currency: "TRY", maximumFractionDigits: 2 }); +const DATE = new Intl.DateTimeFormat("tr-TR", { day: "numeric", month: "long", year: "numeric" }); +const DATE_TIME = new Intl.DateTimeFormat("tr-TR", { + day: "numeric", + month: "short", + hour: "2-digit", + minute: "2-digit", +}); + +export function num(value: number): string { + return NUMBER.format(value); +} + +export function precise(value: number): string { + return PRECISE.format(value); +} + +export function money(value: number): string { + return CURRENCY.format(value); +} + +export function isoDate(iso: string): string { + const [year, month, day] = iso.split("-").map(Number); + return DATE.format(new Date(Date.UTC(year, month - 1, day))); +} + +export function dateTime(value: Date | string): string { + return DATE_TIME.format(new Date(value)); +} + +/** `2 sa 05 dk` — the same shape the car's charging pane uses. */ +export function duration(seconds: number): string { + const total = Math.max(0, Math.round(seconds)); + const hours = Math.floor(total / 3600); + const minutes = Math.floor((total % 3600) / 60); + if (hours === 0) return `${minutes} dk`; + return `${hours} sa ${String(minutes).padStart(2, "0")} dk`; +} + +/** Energy per 100 km, or a dash — never a ratio computed from a distance of nothing. */ +export function efficiency(kwh: number, km: number): string { + if (km < 1) return "—"; + return `${num((kwh * 100) / km)} kWh/100 km`; +} diff --git a/web/src/lib/mongo.ts b/web/src/lib/mongo.ts new file mode 100644 index 0000000..5e85f59 --- /dev/null +++ b/web/src/lib/mongo.ts @@ -0,0 +1,96 @@ +import { MongoClient, type Collection, type Db } from "mongodb"; + +/** + * One MongoClient per warm lambda, cached on globalThis. + * + * A serverless function is re-entered far more often than it is cold-started, and a fresh + * MongoClient per request opens a fresh pool per request — Atlas starts refusing + * connections long before the traffic justifies it. The global survives module reloads in + * dev, where Next re-evaluates modules on every edit. + */ +const globalForMongo = globalThis as unknown as { mongoClient?: Promise }; + +export interface Totals { + km: number; + kwh: number; + hours: number; + socDrop: number; +} + +export interface DailyConsumption { + installationId: string; + date: string; + recordedAt: Date; + day: Totals; + lifetime: Totals; + sohPercent?: number; + createdAt: Date; + updatedAt: Date; +} + +export interface CurvePoint { + timestamp: Date; + powerKw: number; + socPercent?: number; +} + +export interface ChargingSession { + installationId: string; + sessionId: string; + startedAt: Date; + endedAt: Date; + durationSeconds: number; + energyKwh: number; + pricePerKwh: number; + totalCost: number; + startSocPercent?: number; + endSocPercent?: number; + curve: CurvePoint[]; + createdAt: Date; + updatedAt: Date; +} + +function clientPromise(): Promise { + const uri = process.env.MONGODB_URI; + if (!uri) throw new Error("MONGODB_URI is not configured"); + if (!globalForMongo.mongoClient) { + globalForMongo.mongoClient = new MongoClient(uri, { + serverSelectionTimeoutMS: 10_000, + maxPoolSize: 10, + }).connect(); + } + return globalForMongo.mongoClient; +} + +async function db(): Promise { + const client = await clientPromise(); + return client.db(process.env.MONGODB_DATABASE || "fatihsmg4"); +} + +export async function consumptionCollection(): Promise> { + return (await db()).collection("daily_consumption"); +} + +export async function chargingCollection(): Promise> { + return (await db()).collection("charging_sessions"); +} + +/** + * Creates the indexes the Express service used to create at boot. + * + * There is no boot here — a lambda starts on a request — so this runs on the first write + * of each warm instance and is skipped afterwards. `createIndex` is idempotent, but it is + * still a round trip, and the uniqueness constraints are what make the upserts safe. + */ +let indexesEnsured = false; +export async function ensureIndexes(): Promise { + if (indexesEnsured) return; + const [consumption, charging] = await Promise.all([consumptionCollection(), chargingCollection()]); + await Promise.all([ + consumption.createIndex({ installationId: 1, date: 1 }, { unique: true }), + consumption.createIndex({ installationId: 1, date: -1 }), + charging.createIndex({ installationId: 1, sessionId: 1 }, { unique: true }), + charging.createIndex({ installationId: 1, startedAt: -1 }), + ]); + indexesEnsured = true; +} diff --git a/web/src/lib/session.ts b/web/src/lib/session.ts new file mode 100644 index 0000000..db335e2 --- /dev/null +++ b/web/src/lib/session.ts @@ -0,0 +1,80 @@ +import crypto from "node:crypto"; + +/** + * Two separate credentials, deliberately. + * + * The car holds {@link INGEST_TOKEN} and can only write. It is baked into a build that + * lives on a head unit and is published as an APK, so it must be assumed extractable — + * and an extracted write token buys nothing but the ability to post junk under an + * installation id the attacker would have to guess. + * + * Reading the history needs the dashboard password, which never leaves the browser's + * cookie jar and is never compiled into anything. + */ +export const SESSION_COOKIE = "fatihsmg4_session"; +const SESSION_TTL_SECONDS = 60 * 60 * 24 * 30; + +/** Constant-time compare that does not leak length through an early return. */ +function sameSecret(supplied: string, expected: string): boolean { + const a = crypto.createHash("sha256").update(supplied).digest(); + const b = crypto.createHash("sha256").update(expected).digest(); + return crypto.timingSafeEqual(a, b); +} + +function requireEnv(name: string): string { + const value = process.env[name]; + if (!value) throw new Error(`${name} is not configured`); + return value; +} + +/** Does this request carry the car's write token? */ +export function hasIngestToken(request: Request): boolean { + const expected = process.env.INGEST_TOKEN; + if (!expected || expected.length < 24) return false; + const header = request.headers.get("authorization") ?? ""; + if (!header.startsWith("Bearer ")) return false; + return sameSecret(header.slice(7), expected); +} + +export function checkDashboardPassword(supplied: string): boolean { + const expected = process.env.DASHBOARD_PASSWORD; + if (!expected || expected.length < 8) return false; + return sameSecret(supplied, expected); +} + +/** + * `.` — no server-side session store, which a serverless deployment has + * nowhere to keep anyway. The expiry is inside the signed payload, so a browser that + * keeps the cookie past its date cannot use it. + */ +export function issueSessionValue(): string { + const expiresAt = Math.floor(Date.now() / 1000) + SESSION_TTL_SECONDS; + return `${expiresAt}.${sign(String(expiresAt))}`; +} + +function sign(payload: string): string { + return crypto.createHmac("sha256", requireEnv("SESSION_SECRET")).update(payload).digest("hex"); +} + +export function isValidSessionValue(value: string | undefined): boolean { + if (!value) return false; + const separator = value.lastIndexOf("."); + if (separator <= 0) return false; + const expiresAt = Number(value.slice(0, separator)); + if (!Number.isSafeInteger(expiresAt) || expiresAt * 1000 < Date.now()) return false; + try { + const expected = Buffer.from(sign(String(expiresAt)), "hex"); + const supplied = Buffer.from(value.slice(separator + 1), "hex"); + return expected.length === supplied.length && crypto.timingSafeEqual(expected, supplied); + } catch { + return false; + } +} + +export const sessionCookieOptions = { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + maxAge: SESSION_TTL_SECONDS, +} as const; diff --git a/web/src/lib/validate.ts b/web/src/lib/validate.ts new file mode 100644 index 0000000..8e4308f --- /dev/null +++ b/web/src/lib/validate.ts @@ -0,0 +1,105 @@ +/** + * Request validation for the ingest endpoints — a direct port of the Express service's + * rules, kept pure so it can be unit-tested without a database or a request. + * + * The car is the only writer and it is trusted, but a bad field here is written once and + * read forever. Every number is bounded; an out-of-range value is rejected rather than + * stored and quietly plotted. + */ +import type { ChargingSession, CurvePoint, DailyConsumption, Totals } from "./mongo"; + +export class ValidationError extends Error {} + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/; + +export type ConsumptionInput = Omit; +export type ChargingInput = Omit; + +export function validateConsumption(body: unknown): ConsumptionInput { + const record = asObject(body); + const date = record.date; + if (typeof date !== "string" || !ISO_DATE.test(date)) throw new ValidationError("date is invalid"); + const sohPercent = record.sohPercent; + return { + installationId: validateUuid(record.installationId, "installationId"), + date, + recordedAt: validateDate(record.recordedAt, "recordedAt"), + day: validateTotals(record.day, "day"), + lifetime: validateTotals(record.lifetime, "lifetime"), + ...(sohPercent === undefined ? {} : { sohPercent: bounded(sohPercent, 0, 100, "sohPercent") }), + }; +} + +export function validateChargingSession(body: unknown, pathSessionId: string): ChargingInput { + const record = asObject(body); + const sessionId = validateUuid(record.sessionId, "sessionId"); + if (sessionId !== pathSessionId) throw new ValidationError("sessionId does not match path"); + if (!Array.isArray(record.curve) || record.curve.length > 720) { + throw new ValidationError("curve is invalid"); + } + const curve: CurvePoint[] = record.curve.map((entry) => { + const point = asObject(entry); + const socPercent = point.socPercent; + return { + timestamp: validateDate(point.timestampMs, "curve.timestampMs"), + powerKw: bounded(point.powerKw, 0, 1000, "curve.powerKw"), + ...(socPercent === undefined ? {} : { socPercent: bounded(socPercent, 0, 100, "curve.socPercent") }), + }; + }); + const startSocPercent = record.startSocPercent; + const endSocPercent = record.endSocPercent; + return { + installationId: validateUuid(record.installationId, "installationId"), + sessionId, + startedAt: validateDate(record.startedAt, "startedAt"), + endedAt: validateDate(record.endedAt, "endedAt"), + durationSeconds: bounded(record.durationSeconds, 0, 604800, "durationSeconds"), + energyKwh: bounded(record.energyKwh, 0, 1000, "energyKwh"), + pricePerKwh: bounded(record.pricePerKwh, 0, 1_000_000, "pricePerKwh"), + totalCost: bounded(record.totalCost, 0, 1_000_000_000, "totalCost"), + ...(startSocPercent === undefined ? {} : { startSocPercent: bounded(startSocPercent, 0, 100, "startSocPercent") }), + ...(endSocPercent === undefined ? {} : { endSocPercent: bounded(endSocPercent, 0, 100, "endSocPercent") }), + curve, + }; +} + +export function validateUuid(value: unknown, name: string): string { + if (typeof value !== "string" || !UUID.test(value)) throw new ValidationError(`${name} is invalid`); + return value; +} + +function asObject(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new ValidationError("body is required"); + } + return value as Record; +} + +/** Epoch milliseconds on the wire; a Date in the database. */ +function validateDate(value: unknown, name: string): Date { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) { + throw new ValidationError(`${name} is invalid`); + } + return new Date(value); +} + +function bounded(value: unknown, min: number, max: number, name: string): number { + if (typeof value !== "number" || !Number.isFinite(value) || value < min || value > max) { + throw new ValidationError(`${name} is invalid`); + } + return value; +} + +function validateTotals(value: unknown, name: string): Totals { + const record = asObject(value); + const result = {} as Totals; + for (const field of ["km", "kwh", "hours", "socDrop"] as const) { + const number = record[field]; + if (typeof number !== "number" || !Number.isFinite(number) || Math.abs(number) > 10_000_000) { + throw new ValidationError(`${name}.${field} is invalid`); + } + result[field] = number; + } + return result; +} diff --git a/web/test/session.test.ts b/web/test/session.test.ts new file mode 100644 index 0000000..6d14b6f --- /dev/null +++ b/web/test/session.test.ts @@ -0,0 +1,44 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +process.env.SESSION_SECRET = "0123456789abcdef0123456789abcdef"; +process.env.DASHBOARD_PASSWORD = "correct-horse-battery"; +process.env.INGEST_TOKEN = "a".repeat(64); + +const { checkDashboardPassword, hasIngestToken, isValidSessionValue, issueSessionValue } = + await import("../src/lib/session.ts"); + +test("a freshly issued session validates and a tampered one does not", () => { + const value = issueSessionValue(); + assert.equal(isValidSessionValue(value), true); + const [expiry, signature] = value.split("."); + // Moving the expiry out invalidates the signature, so a cookie cannot extend itself. + assert.equal(isValidSessionValue(`${Number(expiry) + 86_400}.${signature}`), false); + assert.equal(isValidSessionValue(`${expiry}.${"0".repeat(signature.length)}`), false); + assert.equal(isValidSessionValue(undefined), false); + assert.equal(isValidSessionValue("garbage"), false); +}); + +test("an expired session is refused even with a valid signature", async () => { + const past = Math.floor(Date.now() / 1000) - 10; + // Signed by the same secret, so only the embedded expiry rejects it. + const crypto = await import("node:crypto"); + const signature = crypto.createHmac("sha256", process.env.SESSION_SECRET as string) + .update(String(past)).digest("hex"); + assert.equal(isValidSessionValue(`${past}.${signature}`), false); +}); + +test("the dashboard password is exact", () => { + assert.equal(checkDashboardPassword("correct-horse-battery"), true); + assert.equal(checkDashboardPassword("correct-horse-batter"), false); + assert.equal(checkDashboardPassword(""), false); +}); + +test("the ingest token is only accepted as a bearer header", () => { + const token = "a".repeat(64); + const bearer = (value: string) => new Request("https://x/v1", { headers: { authorization: value } }); + assert.equal(hasIngestToken(bearer(`Bearer ${token}`)), true); + assert.equal(hasIngestToken(bearer(token)), false); + assert.equal(hasIngestToken(bearer(`Bearer ${"b".repeat(64)}`)), false); + assert.equal(hasIngestToken(new Request("https://x/v1")), false); +}); diff --git a/web/test/validate.test.ts b/web/test/validate.test.ts new file mode 100644 index 0000000..9319d49 --- /dev/null +++ b/web/test/validate.test.ts @@ -0,0 +1,90 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { ValidationError, validateChargingSession, validateConsumption } from "../src/lib/validate.ts"; + +const INSTALLATION = "6f1a2b3c-4d5e-4f60-8a9b-0c1d2e3f4a5b"; +const SESSION = "11111111-2222-4333-8444-555555555555"; + +const day = { km: 42.5, kwh: 7.25, hours: 0.9, socDrop: 11.5 }; + +test("accepts a well-formed daily record", () => { + const record = validateConsumption({ + installationId: INSTALLATION, + date: "2026-10-07", + recordedAt: 1_760_000_000_000, + day, + lifetime: { km: 12000, kwh: 2100, hours: 240, socDrop: 3400 }, + sohPercent: 97.5, + }); + assert.equal(record.date, "2026-10-07"); + assert.equal(record.day.kwh, 7.25); + assert.ok(record.recordedAt instanceof Date); +}); + +test("a missing SOH is omitted rather than defaulted", () => { + // The car omits a field it could not read. Writing 0 here would be a battery at + // nothing, which is the same class of lie the telemetry side refuses to send. + const record = validateConsumption({ + installationId: INSTALLATION, + date: "2026-10-07", + recordedAt: 1, + day, + lifetime: day, + }); + assert.equal("sohPercent" in record, false); +}); + +test("rejects malformed identifiers, dates and out-of-range numbers", () => { + const base = { installationId: INSTALLATION, date: "2026-10-07", recordedAt: 1, day, lifetime: day }; + assert.throws(() => validateConsumption({ ...base, installationId: "nope" }), ValidationError); + assert.throws(() => validateConsumption({ ...base, date: "07.10.2026" }), ValidationError); + assert.throws(() => validateConsumption({ ...base, sohPercent: 140 }), ValidationError); + assert.throws(() => validateConsumption({ ...base, day: { ...day, kwh: "7" } }), ValidationError); + assert.throws(() => validateConsumption({ ...base, recordedAt: -1 }), ValidationError); + assert.throws(() => validateConsumption(null), ValidationError); +}); + +test("a charging session must match the id in its path", () => { + const body = { + installationId: INSTALLATION, + sessionId: SESSION, + startedAt: 1_760_000_000_000, + endedAt: 1_760_002_000_000, + durationSeconds: 1800, + energyKwh: 31.4, + pricePerKwh: 7.5, + totalCost: 235.5, + startSocPercent: 18, + endSocPercent: 72, + curve: [{ timestampMs: 1_760_000_060_000, powerKw: 84.2, socPercent: 20 }], + }; + const record = validateChargingSession(body, SESSION); + assert.equal(record.curve.length, 1); + assert.equal(record.curve[0].powerKw, 84.2); + assert.throws(() => validateChargingSession(body, "11111111-2222-4333-8444-555555555556"), ValidationError); +}); + +test("refuses a curve longer than the car can produce", () => { + const curve = Array.from({ length: 721 }, (_unused, index) => ({ + timestampMs: 1_760_000_000_000 + index * 60_000, + powerKw: 50, + })); + assert.throws( + () => + validateChargingSession( + { + installationId: INSTALLATION, + sessionId: SESSION, + startedAt: 1, + endedAt: 2, + durationSeconds: 1, + energyKwh: 1, + pricePerKwh: 1, + totalCost: 1, + curve, + }, + SESSION, + ), + ValidationError, + ); +}); diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..90069c0 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["dom", "dom.iterable", "ES2022"], + "allowJs": false, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [{ "name": "next" }], + "paths": { "@/*": ["./src/*"] } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +}