From 9654f6ab3e37a3dd633a8e5ee0c2d0492162be03 Mon Sep 17 00:00:00 2001 From: ustaxs Date: Tue, 30 Jun 2026 02:18:22 +0100 Subject: [PATCH 1/3] Startup environment variable validation --- backend/.env.example | 84 ++++--- backend/package-lock.json | 322 ++++++++++++++++++++------- backend/package.json | 5 +- backend/src/app.module.ts | 10 +- backend/src/config/env.validation.ts | 32 +++ 5 files changed, 328 insertions(+), 125 deletions(-) create mode 100644 backend/src/config/env.validation.ts diff --git a/backend/.env.example b/backend/.env.example index ce4a13c..92abcd4 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,57 +1,53 @@ +# +# ==> GENERAL +# PORT=6000 NODE_ENV=development +FRONTEND_URL=http://localhost:3000 + +# +# ==> DATABASE +# +DATABASE_HOST=localhost +DATABASE_PORT=5432 DATABASE_NAME=your_database_name -DATABASE_PASSWORD=your_database_password DATABASE_USERNAME=your_database_username -DATABASE_PORT=5432 -DATABASE_HOST=your_database_host -JWT_SECRET=your_jwt_secret_here -JWT_ACCESS_EXPIRATION=86400000 -JWT_REFRESH_EXPIRATION=604800000 - -# ConvertKit API Configuration -CONVERTKIT_API_KEY=your_convertkit_api_key_here -CONVERTKIT_FORM_ID=your_convertkit_form_id_here +DATABASE_PASSWORD=your_database_password -# Cloudinary Configuration (required for profile pictures) +# +# ==> AUTHENTICATION (JWT) +# +JWT_SECRET=your_jwt_secret_here +JWT_EXPIRATION=1d +JWT_REFRESH_SECRET=your_jwt_refresh_secret_here +JWT_REFRESH_EXPIRATION=7d + +# +# ==> PAYSTACK +# +PAYSTACK_SECRET_KEY=your_paystack_secret_key +PAYSTACK_PUBLIC_KEY=your_paystack_public_key + +# +# ==> CLOUDINARY (for file uploads) +# CLOUDINARY_CLOUD_NAME=your_cloudinary_cloud_name CLOUDINARY_API_KEY=your_cloudinary_api_key CLOUDINARY_API_SECRET=your_cloudinary_api_secret -CLOUDINARY_FOLDER=profile-pictures - -# Email Configuration -# Production SMTP Settings (Gmail, SendGrid, AWS SES, etc.) -SMTP_HOST=smtp.gmail.com -SMTP_PORT=587 -SMTP_SECURE=false -SMTP_USER=your_email@gmail.com -SMTP_PASS=your_app_password -SMTP_FROM_EMAIL=noreply@yourdomain.com - -# Mailtrap (Development/Testing Only) -MAILTRAP_HOST=smtp.mailtrap.io -MAILTRAP_PORT=2525 -MAILTRAP_USER=your_mailtrap_user -MAILTRAP_PASS=your_mailtrap_pass - -# Company Information -COMPANY_NAME=ManageHub -SUPPORT_EMAIL=support@yourdomain.com -FRONTEND_URL=http://localhost:3000 -# Redis Configuration (for Bull Queue) +# +# ==> REDIS (for background jobs & caching) +# REDIS_HOST=localhost REDIS_PORT=6379 -REDIS_PASSWORD= -REDIS_DB=0 -# Email Rate Limiting -EMAIL_RATE_LIMIT_PER_HOUR=100 +# +# ==> EMAIL (SendGrid) +# +SENDGRID_API_KEY=your_sendgrid_api_key -# Email Provider Webhook Secrets (Optional) -SENDGRID_WEBHOOK_SECRET=your_sendgrid_webhook_secret -MAILGUN_WEBHOOK_SECRET=your_mailgun_webhook_secret -SES_SNS_TOPIC_ARN=your_ses_sns_topic_arn - -# Access Control -DEVICE_WEBHOOK_API_KEY=your_device_webhook_api_key_here \ No newline at end of file +# +# ==> OTHER +# +COMPANY_NAME=ManageHub +SUPPORT_EMAIL=support@yourdomain.com \ No newline at end of file diff --git a/backend/package-lock.json b/backend/package-lock.json index f3987fe..08dbd39 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -45,9 +45,11 @@ "cookie-parser": "^1.4.7", "exceljs": "^4.4.0", "express": "^5.2.1", + "googleapis": "^144.0.0", "handlebars": "^4.7.8", "i18n": "^0.15.2", "ioredis": "^5.9.2", + "joi": "^18.2.3", "json2csv": "^6.0.0-alpha.2", "mjml": "^4.18.0", "moment": "^2.30.1", @@ -1094,7 +1096,6 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -1827,6 +1828,54 @@ "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", "license": "MIT" }, + "node_modules/@hapi/address": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz", + "integrity": "sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@hapi/formula": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@hapi/formula/-/formula-3.0.2.tgz", + "integrity": "sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/hoek": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-11.0.7.tgz", + "integrity": "sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/pinpoint": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@hapi/pinpoint/-/pinpoint-2.0.1.tgz", + "integrity": "sha512-EKQmr16tM8s16vTT3cA5L0kZZcTMU5DUOZTuvpnY738m+jyP3JIUj+Mm1xc1rsLkGBQ/gVnfKYPwOmPg1tUR4Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/tlds": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@hapi/tlds/-/tlds-1.1.7.tgz", + "integrity": "sha512-MgNjRwy9Ti92yVAixLmDc8dd1bJIKwO9qlWCfFQRwRmUEDPQHYn4G6hwPFvFGUTzAa0FsS+inMjLin7GnyBRhA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@hapi/topo": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-6.0.2.tgz", + "integrity": "sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", @@ -3648,7 +3697,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-10.4.22.tgz", "integrity": "sha512-fxJ4v85nDHaqT1PmfNCQ37b/jcv2OojtXTaK1P2uAXhzLf9qq6WNUOFvxBrV4fhQek1EQoT1o9oj5xAZmv3NRw==", "license": "MIT", - "peer": true, "dependencies": { "file-type": "20.4.1", "iterare": "1.2.1", @@ -3695,7 +3743,6 @@ "integrity": "sha512-6IX9+VwjiKtCjx+mXVPncpkQ5ZjKfmssOZPFexmT+6T9H9wZ3svpYACAo7+9e7Nr9DZSoRZw3pffkJP7Z0UjaA==", "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@nuxtjs/opencollective": "0.3.2", "fast-safe-stringify": "2.1.1", @@ -3776,7 +3823,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.4.22.tgz", "integrity": "sha512-ySSq7Py/DFozzZdNDH67m/vHoeVdphDniWBnl6q5QVoXldDdrZIHLXLRMPayTDh5A95nt7jjJzmD4qpTbNQ6tA==", "license": "MIT", - "peer": true, "dependencies": { "body-parser": "1.20.4", "cors": "2.8.5", @@ -4294,20 +4340,6 @@ } } }, - "node_modules/@nestjs/schematics/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "optional": true, - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/@nestjs/schematics/node_modules/magic-string": { "version": "0.30.8", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz", @@ -4334,34 +4366,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/@nestjs/schematics/node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/@nestjs/schematics/node_modules/readdirp/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/@nestjs/schematics/node_modules/rxjs": { "version": "7.8.1", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", @@ -4468,7 +4472,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-10.4.22.tgz", "integrity": "sha512-OLd4i0Faq7vgdtB5vVUrJ54hWEtcXy9poJ6n7kbbh/5ms+KffUl+wwGsbe7uSXLrkoyI8xXU6fZPkFArI+XiRg==", "license": "MIT", - "peer": true, "dependencies": { "iterare": "1.2.1", "object-hash": "3.0.0", @@ -5493,6 +5496,12 @@ "integrity": "sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==", "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, "node_modules/@stellar/js-xdr": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@stellar/js-xdr/-/js-xdr-3.1.2.tgz", @@ -5729,7 +5738,6 @@ "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" @@ -5757,7 +5765,6 @@ "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", @@ -5897,7 +5904,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.35.tgz", "integrity": "sha512-Uarfe6J91b9HAUXxjvSOdiO2UPOKLm07Q1oh0JHxoZ1y8HoqxDAu3gVrsrOHeiio0kSsoVBt4wFrKOm0dKxVPQ==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -6120,7 +6126,6 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -6517,7 +6522,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6577,7 +6581,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -6929,7 +6932,6 @@ "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz", "integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==", "license": "MIT", - "peer": true, "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", @@ -7355,7 +7357,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -7460,7 +7461,6 @@ "resolved": "https://registry.npmjs.org/bull/-/bull-4.16.5.tgz", "integrity": "sha512-lDsx2BzkKe7gkCYiT5Acj02DpTwDznl/VNN7Psn7M3USPG7Vs/BaClZJJTAG+ufAR9++N1/NiUTdaFBWDIl5TQ==", "license": "MIT", - "peer": true, "dependencies": { "cron-parser": "^4.9.0", "get-port": "^5.1.1", @@ -7499,7 +7499,6 @@ "resolved": "https://registry.npmjs.org/cache-manager/-/cache-manager-7.2.8.tgz", "integrity": "sha512-0HDaDLBBY/maa/LmUVAr70XUOwsiQD+jyzCBjmUErYZUKdMS9dT59PqW59PpVqfGM7ve6H0J6307JTpkCYefHQ==", "license": "MIT", - "peer": true, "dependencies": { "@cacheable/utils": "^2.3.3", "keyv": "^5.5.5" @@ -7734,7 +7733,6 @@ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "license": "MIT", - "peer": true, "dependencies": { "readdirp": "^4.0.1" }, @@ -7790,15 +7788,13 @@ "version": "0.5.1", "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/class-validator": { "version": "0.14.4", "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.4.tgz", "integrity": "sha512-AwNusCCam51q703dW82x95tOqQp6oC9HNUl724KxJJOfnKscI8dOloXFgyez7LbTTKWuRBA37FScqVbJEoq8Yw==", "license": "MIT", - "peer": true, "dependencies": { "@types/validator": "^13.15.3", "libphonenumber-js": "^1.11.1", @@ -9008,7 +9004,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -9065,7 +9060,6 @@ "integrity": "sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -9492,6 +9486,12 @@ "node": ">= 0.6" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/fast-csv": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/fast-csv/-/fast-csv-4.3.6.tgz", @@ -10085,6 +10085,72 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gaxios": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", + "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/gaxios/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/gaxios/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/gaxios/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/gcp-metadata": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", + "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^6.1.1", + "google-logging-utils": "^0.0.2", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -10228,6 +10294,76 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/google-auth-library": { + "version": "9.15.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", + "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^6.1.1", + "gcp-metadata": "^6.1.0", + "gtoken": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-logging-utils": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", + "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/googleapis": { + "version": "144.0.0", + "resolved": "https://registry.npmjs.org/googleapis/-/googleapis-144.0.0.tgz", + "integrity": "sha512-ELcWOXtJxjPX4vsKMh+7V+jZvgPwYMlEhQFiu2sa9Qmt5veX8nwXPksOWGGN6Zk4xCiLygUyaz7xGtcMO+Onxw==", + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^9.0.0", + "googleapis-common": "^7.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/googleapis-common": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/googleapis-common/-/googleapis-common-7.2.0.tgz", + "integrity": "sha512-/fhDZEJZvOV3X5jmD+fKxMqma5q2Q9nZNSF3kn1F18tpxmA86BcTxAGBQdM0N89Z3bEaIs+HVznSmFJEAmMTjA==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "gaxios": "^6.0.3", + "google-auth-library": "^9.7.0", + "qs": "^6.7.0", + "url-template": "^2.0.8", + "uuid": "^9.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/googleapis-common/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -10253,6 +10389,19 @@ "dev": true, "license": "MIT" }, + "node_modules/gtoken": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", + "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", + "license": "MIT", + "dependencies": { + "gaxios": "^6.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/handlebars": { "version": "4.7.9", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", @@ -10774,7 +10923,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -10933,7 +11081,6 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -11651,6 +11798,24 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/joi": { + "version": "18.2.3", + "resolved": "https://registry.npmjs.org/joi/-/joi-18.2.3.tgz", + "integrity": "sha512-N5A3KTWQpPWT4ExxxPlUx7WmykGXRzhNidWhV41d6Abu9YfI2NyWCJuxdPnslJCPWtbRpSVOWSnSS6GakLM/Rg==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/address": "^5.1.1", + "@hapi/formula": "^3.0.2", + "@hapi/hoek": "^11.0.7", + "@hapi/pinpoint": "^2.0.1", + "@hapi/tlds": "^1.1.1", + "@hapi/topo": "^6.0.2", + "@standard-schema/spec": "^1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/jpeg-exif": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/jpeg-exif/-/jpeg-exif-1.1.4.tgz", @@ -11789,6 +11954,15 @@ "node": ">=6" } }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -11996,7 +12170,6 @@ "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", "license": "MIT", - "peer": true, "dependencies": { "@keyv/serialize": "^1.1.1" } @@ -13461,7 +13634,6 @@ "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz", "integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==", "license": "MIT-0", - "peer": true, "engines": { "node": ">=6.0.0" } @@ -13807,7 +13979,6 @@ "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", "license": "MIT", - "peer": true, "dependencies": { "passport-strategy": "1.x.x", "pause": "0.0.1", @@ -13962,7 +14133,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.19.0.tgz", "integrity": "sha512-QIcLGi508BAHkQ3pJNptsFz5WQMlpGbuBGBaIaXsWK8mel2kQ/rThYI+DbgjUvZrIr7MiuEuc9LcChJoEZK1xQ==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.11.0", "pg-pool": "^3.12.0", @@ -14231,7 +14401,6 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -14936,7 +15105,6 @@ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -15008,7 +15176,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -16258,7 +16425,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -16442,7 +16608,6 @@ "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.28.tgz", "integrity": "sha512-6GH7wXhtfq2D33ZuRXYwIsl/qM5685WZcODZb7noOOcRMteM9KF2x2ap3H0EBjnSV0VO4gNAfJT5Ukp0PkOlvg==", "license": "MIT", - "peer": true, "dependencies": { "@sqltools/formatter": "^1.2.5", "ansis": "^4.2.0", @@ -16667,7 +16832,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -16867,6 +17031,12 @@ "integrity": "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==", "license": "MIT" }, + "node_modules/url-template": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz", + "integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==", + "license": "BSD" + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -17096,7 +17266,6 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.106.2.tgz", "integrity": "sha512-wGN3qcrBQIFmQ/c0AiOAQBvrZ5lmY8vbbMv4Mxfgzqd/B6+9pXtLo73WuS1dSGXM5QYY3hZnIbvx+K1xxe6FyA==", "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -17414,7 +17583,6 @@ "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", "license": "MIT", - "peer": true, "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", diff --git a/backend/package.json b/backend/package.json index d489fe8..258a6d6 100644 --- a/backend/package.json +++ b/backend/package.json @@ -64,9 +64,11 @@ "cookie-parser": "^1.4.7", "exceljs": "^4.4.0", "express": "^5.2.1", + "googleapis": "^144.0.0", "handlebars": "^4.7.8", "i18n": "^0.15.2", "ioredis": "^5.9.2", + "joi": "^18.2.3", "json2csv": "^6.0.0-alpha.2", "mjml": "^4.18.0", "moment": "^2.30.1", @@ -87,8 +89,7 @@ "sharp": "^0.34.5", "socket.io": "^4.8.3", "swagger-ui-express": "^5.0.1", - "typeorm": "^0.3.27", - "googleapis": "^144.0.0" + "typeorm": "^0.3.27" }, "devDependencies": { "@nestjs/schematics": "^10.0.0", diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 88e247a..3de0069 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -51,9 +51,15 @@ import { ResourcesModule } from './resources/resources.module'; import { NpsModule } from './nps/nps.module'; import { DoorAccessModule } from './integrations/access-control/door-access.module'; +import { validationSchema } from './config/env.validation'; + @Module({ imports: [ - ConfigModule.forRoot({ isGlobal: true }), + ConfigModule.forRoot({ + isGlobal: true, + validationSchema, + validationOptions: { abortEarly: false }, + }), ScheduleModule.forRoot(), ThrottlerModule.forRoot([ { @@ -164,4 +170,4 @@ import { DoorAccessModule } from './integrations/access-control/door-access.modu { provide: APP_GUARD, useClass: ThrottlerGuard }, ], }) -export class AppModule {} +export class AppModule {} \ No newline at end of file diff --git a/backend/src/config/env.validation.ts b/backend/src/config/env.validation.ts new file mode 100644 index 0000000..1fb8a66 --- /dev/null +++ b/backend/src/config/env.validation.ts @@ -0,0 +1,32 @@ +import * as Joi from 'joi'; + +export const validationSchema = Joi.object({ + // DB + DATABASE_HOST: Joi.string().required(), + DATABASE_PORT: Joi.number().required(), + DATABASE_NAME: Joi.string().required(), + DATABASE_USERNAME: Joi.string().required(), + DATABASE_PASSWORD: Joi.string().required(), + + // JWT + JWT_SECRET: Joi.string().required(), + JWT_EXPIRATION: Joi.string().required(), + JWT_REFRESH_SECRET: Joi.string().required(), + JWT_REFRESH_EXPIRATION: Joi.string().required(), + + // Paystack + PAYSTACK_SECRET_KEY: Joi.string().required(), + PAYSTACK_PUBLIC_KEY: Joi.string().required(), + + // Cloudinary + CLOUDINARY_CLOUD_NAME: Joi.string().required(), + CLOUDINARY_API_KEY: Joi.string().required(), + CLOUDINARY_API_SECRET: Joi.string().required(), + + // Redis + REDIS_HOST: Joi.string().required(), + REDIS_PORT: Joi.number().required(), + + // Email + SENDGRID_API_KEY: Joi.string().required(), +}); \ No newline at end of file From 6b745b1b190b1d122b8754e7d3e50ef857dcfb14 Mon Sep 17 00:00:00 2001 From: ustaxs Date: Tue, 30 Jun 2026 02:21:00 +0100 Subject: [PATCH 2/3] Structured logging with Pino --- backend/package-lock.json | 256 ++++++++++++++++++++++++++++++++++++++ backend/package.json | 3 + backend/src/app.module.ts | 14 +++ backend/src/main.ts | 8 +- 4 files changed, 277 insertions(+), 4 deletions(-) diff --git a/backend/package-lock.json b/backend/package-lock.json index 08dbd39..a4ee200 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -56,6 +56,7 @@ "multer": "^2.0.2", "nestjs-command": "^3.1.5", "nestjs-i18n": "^10.5.1", + "nestjs-pino": "^4.6.1", "nodemailer": "^7.0.12", "nodemailer-mjml": "^1.6.0", "otplib": "^13.3.0", @@ -64,6 +65,8 @@ "passport-local": "^1.0.0", "pdfkit": "^0.17.2", "pg": "^8.16.3", + "pino-http": "^11.0.0", + "pino-pretty": "^13.1.3", "qrcode": "^1.5.4", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", @@ -4655,6 +4658,12 @@ "@noble/hashes": "^1.1.5" } }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -6912,6 +6921,15 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -7967,6 +7985,12 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "license": "MIT" + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -8307,6 +8331,15 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/dateformat": { + "version": "4.6.3", + "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", + "integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/dayjs": { "version": "1.11.19", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", @@ -9492,6 +9525,12 @@ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, + "node_modules/fast-copy": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.3.tgz", + "integrity": "sha512-58apWr0GUiDFM8+3afrO6eYwJBn9ZAhDOzG3L+/9llab/haCARS2UIfffmOurYLwbgDRs8n0rfr6qAAPEAuAQw==", + "license": "MIT" + }, "node_modules/fast-csv": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/fast-csv/-/fast-csv-4.3.6.tgz", @@ -10523,6 +10562,12 @@ "he": "bin/he" } }, + "node_modules/help-me": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", + "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==", + "license": "MIT" + }, "node_modules/hookified": { "version": "1.15.1", "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", @@ -11816,6 +11861,15 @@ "node": ">= 20" } }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/jpeg-exif": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/jpeg-exif/-/jpeg-exif-1.1.4.tgz", @@ -13534,6 +13588,21 @@ "node": ">=8.10.0" } }, + "node_modules/nestjs-pino": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/nestjs-pino/-/nestjs-pino-4.6.1.tgz", + "integrity": "sha512-nuARXa0xpdJ1lY2+fgycIQr6H3g0VgqAWNK3xMYjOFcj2DoPETNXj0lV3Y86nRuI7BUfQp5PGiVoZvT4dTWbpQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", + "pino": "^7.5.0 || ^8.0.0 || ^9.0.0 || ^10.0.0", + "pino-http": "^6.4.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", + "rxjs": "^7.1.0" + } + }, "node_modules/no-case": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/no-case/-/no-case-2.3.2.tgz", @@ -13736,6 +13805,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -14235,6 +14313,91 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-http": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/pino-http/-/pino-http-11.0.0.tgz", + "integrity": "sha512-wqg5XIAGRRIWtTk8qPGxkbrfiwEWz1lgedVLvhLALudKXvg1/L2lTFgTGPJ4Z2e3qcRmxoFxDuSdMdMGNM6I1g==", + "license": "MIT", + "dependencies": { + "get-caller-file": "^2.0.5", + "pino": "^10.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0" + } + }, + "node_modules/pino-pretty": { + "version": "13.1.3", + "resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-13.1.3.tgz", + "integrity": "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==", + "license": "MIT", + "dependencies": { + "colorette": "^2.0.7", + "dateformat": "^4.6.3", + "fast-copy": "^4.0.0", + "fast-safe-stringify": "^2.1.1", + "help-me": "^5.0.0", + "joycon": "^3.1.1", + "minimist": "^1.2.6", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pump": "^3.0.0", + "secure-json-parse": "^4.0.0", + "sonic-boom": "^4.0.1", + "strip-json-comments": "^5.0.2" + }, + "bin": { + "pino-pretty": "bin.js" + } + }, + "node_modules/pino-pretty/node_modules/strip-json-comments": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", + "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, "node_modules/pirates": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", @@ -14458,6 +14621,22 @@ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "license": "MIT" }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -14500,6 +14679,16 @@ "node": ">=10" } }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -14683,6 +14872,12 @@ ], "license": "MIT" }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -14798,6 +14993,15 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, "node_modules/redis-errors": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", @@ -15135,6 +15339,15 @@ "integrity": "sha512-6pNbSMW6OhAi9j+N8V+U715yBQsaWJ7eyEUaOrawX+isg5ZxhUlV1NipNtgaKHmFGiABwt+ZF04Ii+3Xjkg+8w==", "license": "ISC" }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -15202,6 +15415,22 @@ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "license": "MIT" }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/semver": { "version": "7.8.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", @@ -15583,6 +15812,15 @@ "require-addon": "^1.1.0" } }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/source-map": { "version": "0.7.4", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", @@ -16206,6 +16444,24 @@ "dev": true, "license": "MIT" }, + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/thread-stream/node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "license": "MIT" + }, "node_modules/tiny-inflate": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", diff --git a/backend/package.json b/backend/package.json index 258a6d6..873a4b9 100644 --- a/backend/package.json +++ b/backend/package.json @@ -75,6 +75,7 @@ "multer": "^2.0.2", "nestjs-command": "^3.1.5", "nestjs-i18n": "^10.5.1", + "nestjs-pino": "^4.6.1", "nodemailer": "^7.0.12", "nodemailer-mjml": "^1.6.0", "otplib": "^13.3.0", @@ -83,6 +84,8 @@ "passport-local": "^1.0.0", "pdfkit": "^0.17.2", "pg": "^8.16.3", + "pino-http": "^11.0.0", + "pino-pretty": "^13.1.3", "qrcode": "^1.5.4", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 3de0069..76b64e9 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -53,8 +53,22 @@ import { DoorAccessModule } from './integrations/access-control/door-access.modu import { validationSchema } from './config/env.validation'; +import { LoggerModule } from 'nestjs-pino'; + @Module({ imports: [ + LoggerModule.forRoot({ + pinoHttp: { + transport: { + target: 'pino-pretty', + options: { + singleLine: true, + }, + }, + redact: ['req.headers.authorization', '*.password', '*.totpSecret', '*.smsOtpCode'], + genReqId: (req) => req.headers['x-request-id'], + }, + }), ConfigModule.forRoot({ isGlobal: true, validationSchema, diff --git a/backend/src/main.ts b/backend/src/main.ts index 1d9f864..20a484d 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -3,12 +3,12 @@ import { AppModule } from './app.module'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { ValidationPipe, ClassSerializerInterceptor } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; -import { HttpLogger } from './common/middlewares/httpLogger.middleware'; +import { Logger } from 'nestjs-pino'; async function bootstrap() { - const app = await NestFactory.create(AppModule, { rawBody: true }); + const app = await NestFactory.create(AppModule, { bufferLogs: true }); - app.use(new HttpLogger().use); + app.useLogger(app.get(Logger)); // GLOBAL VALIDATION app.useGlobalPipes( @@ -53,4 +53,4 @@ async function bootstrap() { await app.listen(process.env.PORT ?? 3000, '0.0.0.0'); console.log(`Server is listening at: ${await app.getUrl()}`); } -bootstrap(); +bootstrap(); \ No newline at end of file From 0881155aa1068d55b0b82e90b3ab2f8d9aa925fe Mon Sep 17 00:00:00 2001 From: ustaxs Date: Tue, 30 Jun 2026 02:27:53 +0100 Subject: [PATCH 3/3] Global search palette (Cmd+K / Ctrl+K) --- backend/src/app.module.ts | 2 + backend/src/search/search.controller.ts | 19 +++ backend/src/search/search.module.ts | 14 ++ backend/src/search/search.service.ts | 89 +++++++++++++ frontend/app/bookings/page.tsx | 45 ++++++- frontend/app/layout.tsx | 8 +- .../components/bookings/BookingCalendar.tsx | 73 +++++++++++ .../components/dashboard/DashboardLayout.tsx | 27 +++- frontend/components/search/GlobalSearch.tsx | 122 ++++++++++++++++++ frontend/hooks/useDebounce.ts | 17 +++ .../hooks/search/useGlobalSearch.ts | 15 +++ 11 files changed, 425 insertions(+), 6 deletions(-) create mode 100644 backend/src/search/search.controller.ts create mode 100644 backend/src/search/search.module.ts create mode 100644 backend/src/search/search.service.ts create mode 100644 frontend/components/bookings/BookingCalendar.tsx create mode 100644 frontend/components/search/GlobalSearch.tsx create mode 100644 frontend/hooks/useDebounce.ts create mode 100644 frontend/lib/react-query/hooks/search/useGlobalSearch.ts diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 76b64e9..8f79a2c 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -49,6 +49,7 @@ import { TeamsModule } from './teams/teams.module'; import { CalendarSyncModule } from './calendar-sync/calendar-sync.module'; import { ResourcesModule } from './resources/resources.module'; import { NpsModule } from './nps/nps.module'; +import { SearchModule } from './search/search.module'; import { DoorAccessModule } from './integrations/access-control/door-access.module'; import { validationSchema } from './config/env.validation'; @@ -176,6 +177,7 @@ import { LoggerModule } from 'nestjs-pino'; ResourcesModule, NpsModule, DoorAccessModule, + SearchModule, ], controllers: [AppController], providers: [ diff --git a/backend/src/search/search.controller.ts b/backend/src/search/search.controller.ts new file mode 100644 index 0000000..3aad828 --- /dev/null +++ b/backend/src/search/search.controller.ts @@ -0,0 +1,19 @@ +import { Controller, Get, Query, UseGuards, Req } from '@nestjs/common'; +import { SearchService } from './search.service'; +import { JwtAuthGuard } from '../auth/guard/jwt.auth.guard'; +import { Request } from 'express'; + +@Controller('search') +@UseGuards(JwtAuthGuard) +export class SearchController { + constructor(private readonly searchService: SearchService) {} + + @Get() + search( + @Query('q') query: string, + @Query('types') types: string, + @Req() req: Request, + ) { + return this.searchService.search(query, types, req.user); + } +} \ No newline at end of file diff --git a/backend/src/search/search.module.ts b/backend/src/search/search.module.ts new file mode 100644 index 0000000..85b0f9c --- /dev/null +++ b/backend/src/search/search.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { SearchController } from './search.controller'; +import { SearchService } from './search.service'; +import { WorkspacesModule } from '../workspaces/workspaces.module'; +import { BookingsModule } from '../bookings/bookings.module'; +import { EventsModule } from '../events/events.module'; +import { UsersModule } from '../users/users.module'; + +@Module({ + imports: [WorkspacesModule, BookingsModule, EventsModule, UsersModule], + controllers: [SearchController], + providers: [SearchService], +}) +export class SearchModule {} \ No newline at end of file diff --git a/backend/src/search/search.service.ts b/backend/src/search/search.service.ts new file mode 100644 index 0000000..9023e98 --- /dev/null +++ b/backend/src/search/search.service.ts @@ -0,0 +1,89 @@ +import { Injectable } from '@nestjs/common'; +import { WorkspacesService } from '../workspaces/workspaces.service'; +import { BookingsService } from '../bookings/bookings.service'; +import { EventsService } from '../events/events.service'; +import { UsersService } from '../users/providers/users.service'; +import { UserRole } from '../users/enums/userRoles.enum'; + +@Injectable() +export class SearchService { + constructor( + private readonly workspacesService: WorkspacesService, + private readonly bookingsService: BookingsService, + private readonly eventsService: EventsService, + private readonly usersService: UsersService, + ) {} + + async search(query: string, types: string, user: any) { + const typesArr = types ? types.split(',') : ['workspaces', 'bookings', 'events', 'members']; + const results = []; + + if (typesArr.includes('workspaces')) { + const { data: workspaces } = await this.workspacesService.findAll({ search: query, limit: 5 }); + workspaces.forEach((workspace) => { + results.push({ + type: 'workspaces', + id: workspace.id, + title: workspace.name, + subtitle: workspace.type, + href: `/workspaces/${workspace.id}`, + }); + }); + } + + if (typesArr.includes('bookings')) { + // Assuming no direct search, so filtering after fetching + const { data: bookings } = await this.bookingsService.findAll({ limit: 100 }, user.id, user.role); + const filteredBookings = bookings + .filter( + (booking) => + booking.workspace?.name.toLowerCase().includes(query.toLowerCase()) || + booking.id.toLowerCase().includes(query.toLowerCase()), + ) + .slice(0, 5); + + filteredBookings.forEach((booking) => { + results.push({ + type: 'bookings', + id: booking.id, + title: booking.workspace?.name || `Booking #${booking.id.slice(0, 8)}`, + subtitle: `${booking.status} ยท ${new Date(booking.startDate).toLocaleDateString()}`, + href: `/bookings/${booking.id}`, + }); + }); + } + + if (typesArr.includes('events')) { + // Assuming no direct search, so filtering after fetching + const { data: events } = await this.eventsService.findAll(1, 100); + const filteredEvents = events + .filter((event) => event.title.toLowerCase().includes(query.toLowerCase())) + .slice(0, 5); + + filteredEvents.forEach((event) => { + results.push({ + type: 'events', + id: event.id, + title: event.title, + subtitle: new Date(event.date).toLocaleDateString(), + href: `/events/${event.id}`, + }); + }); + } + + if (typesArr.includes('members') && user.role === UserRole.ADMIN) { + const { data: members } = await this.usersService.getMembers({ search: query, limit: 5 }); + members.forEach((member) => { + results.push({ + type: 'members', + id: member.id, + title: `${member.firstName} ${member.lastName}`, + subtitle: member.email, + href: `/admin/members/${member.id}`, + }); + }); + } + + return { results }; + } +} \ No newline at end of file diff --git a/frontend/app/bookings/page.tsx b/frontend/app/bookings/page.tsx index d105acf..6f51920 100644 --- a/frontend/app/bookings/page.tsx +++ b/frontend/app/bookings/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import Link from "next/link"; import DashboardLayout from "@/components/dashboard/DashboardLayout"; import { useGetMyBookings } from "@/lib/react-query/hooks/bookings/useGetMyBookings"; @@ -15,6 +15,7 @@ import { XCircle, } from "lucide-react"; import { toast } from "sonner"; +import BookingCalendar from "@/components/bookings/BookingCalendar"; const STATUS_STYLES: Record = { PENDING: "bg-amber-50 text-amber-700", @@ -168,6 +169,19 @@ function BookingRow({ booking, onCancelled }: { booking: Booking; onCancelled: ( export default function MyBookingsPage() { const [page, setPage] = useState(1); const { data, isLoading, isError, refetch } = useGetMyBookings(page, 10); + const [view, setView] = useState('list'); + + useEffect(() => { + const savedView = localStorage.getItem('booking_view'); + if (savedView) { + setView(savedView); + } + }, []); + + const handleViewChange = (newView: string) => { + setView(newView); + localStorage.setItem('booking_view', newView); + }; const bookings = data?.data ?? []; const meta = data?.meta; @@ -190,7 +204,32 @@ export default function MyBookingsPage() { - {isLoading ? ( +
+ + +
+ + {view === 'calendar' ? ( + + ) : isLoading ? (
{[1, 2, 3].map((i) => (
); -} +} \ No newline at end of file diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 360f040..56a5746 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -4,6 +4,7 @@ import Providers from "@/providers/Providers"; import "./globals.css"; import { Toaster } from "sonner"; import { ThemeProvider } from "@/components/theme-provider"; +import GlobalSearch from "@/components/search/GlobalSearch"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -117,10 +118,13 @@ export default function RootLayout({ className={`${geistSans.variable} ${geistMono.variable} antialiased bg-white dark:bg-gray-950 text-gray-900 dark:text-gray-50`} > - {children} + + {children} + + ); -} +} \ No newline at end of file diff --git a/frontend/components/bookings/BookingCalendar.tsx b/frontend/components/bookings/BookingCalendar.tsx new file mode 100644 index 0000000..8d2407f --- /dev/null +++ b/frontend/components/bookings/BookingCalendar.tsx @@ -0,0 +1,73 @@ +'use client'; + +import { useState } from 'react'; + +const BookingCalendar = () => { + const [currentDate, setCurrentDate] = useState(new Date()); + + const daysInMonth = (month: number, year: number) => { + return new Date(year, month + 1, 0).getDate(); + }; + + const getMonthData = () => { + const year = currentDate.getFullYear(); + const month = currentDate.getMonth(); + const firstDay = new Date(year, month, 1).getDay(); + const totalDays = daysInMonth(month, year); + const weeks = []; + let day = 1; + + for (let i = 0; i < 6; i++) { + const week = []; + for (let j = 0; j < 7; j++) { + if (i === 0 && j < firstDay) { + week.push(null); + } else if (day > totalDays) { + week.push(null); + } else { + week.push(day); + day++; + } + } + weeks.push(week); + if (day > totalDays) break; + } + return weeks; + }; + + const weeks = getMonthData(); + + return ( +
+
+ +

+ {currentDate.toLocaleString('default', { + month: 'long', + year: 'numeric', + })} +

+ +
+
+ {['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map((day) => ( +
+ {day} +
+ ))} + {weeks.map((week, i) => + week.map((day, j) => ( +
+ {day} +
+ )) + )} +
+
+ ); +}; + +export default BookingCalendar; \ No newline at end of file diff --git a/frontend/components/dashboard/DashboardLayout.tsx b/frontend/components/dashboard/DashboardLayout.tsx index 6973727..faf72f2 100644 --- a/frontend/components/dashboard/DashboardLayout.tsx +++ b/frontend/components/dashboard/DashboardLayout.tsx @@ -1,5 +1,6 @@ "use client"; +import { Search } from "lucide-react"; import DashboardSidebar from "./DashboardSidebar"; export default function DashboardLayout({ @@ -7,14 +8,38 @@ export default function DashboardLayout({ }: { children: React.ReactNode; }) { + const openSearch = () => { + // This is a bit of a hack, but it's the easiest way to open the search palette + // from outside the component. + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', ctrlKey: true })); + }; + return (
+
+ +
+
+ +
{children}
); -} +} \ No newline at end of file diff --git a/frontend/components/search/GlobalSearch.tsx b/frontend/components/search/GlobalSearch.tsx new file mode 100644 index 0000000..c6293a4 --- /dev/null +++ b/frontend/components/search/GlobalSearch.tsx @@ -0,0 +1,122 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { Search, X } from 'lucide-react'; +import { useGlobalSearch } from '@/lib/react-query/hooks/search/useGlobalSearch'; +import { useDebounce } from '@/hooks/useDebounce'; +import Link from 'next/link'; + +const GlobalSearch = () => { + const [isOpen, setIsOpen] = useState(false); + const [query, setQuery] = useState(''); + const debouncedQuery = useDebounce(query, 300); + const { data, isLoading } = useGlobalSearch(debouncedQuery, ''); + const [recentSearches, setRecentSearches] = useState([]); + + useEffect(() => { + const storedRecents = localStorage.getItem('recent_searches'); + if (storedRecents) { + setRecentSearches(JSON.parse(storedRecents)); + } + }, []); + + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + if ((event.metaKey || event.ctrlKey) && event.key === 'k') { + setIsOpen((prev) => !prev); + } else if (event.key === 'Escape') { + setIsOpen(false); + } + }, + [], + ); + + useEffect(() => { + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + }, [handleKeyDown]); + + const handleSelect = (href: string) => { + setIsOpen(false); + const updatedRecents = [query, ...recentSearches.filter((s) => s !== query)].slice(0, 5); + setRecentSearches(updatedRecents); + localStorage.setItem('recent_searches', JSON.stringify(updatedRecents)); + }; + + if (!isOpen) { + return null; + } + + const results = data?.results || []; + const groupedResults = results.reduce((acc, result) => { + if (!acc[result.type]) { + acc[result.type] = []; + } + acc[result.type].push(result); + return acc; + }, {}); + + return ( +
+
+
+
+ + setQuery(e.target.value)} + autoFocus + /> + +
+
+
+ {query === '' && recentSearches.length > 0 && ( +
+

Recent Searches

+
    + {recentSearches.map((search, i) => ( +
  • + +
  • + ))} +
+
+ )} + {isLoading &&
Searching...
} + {Object.keys(groupedResults).map((type) => ( +
+

{type}

+
    + {groupedResults[type].map((item) => ( +
  • + handleSelect(item.href)}> +
    +
    {item.title}
    +
    {item.subtitle}
    +
    + +
  • + ))} +
+
+ ))} +
+
+
+ ); +}; + +export default GlobalSearch; \ No newline at end of file diff --git a/frontend/hooks/useDebounce.ts b/frontend/hooks/useDebounce.ts new file mode 100644 index 0000000..2605ca6 --- /dev/null +++ b/frontend/hooks/useDebounce.ts @@ -0,0 +1,17 @@ +import { useState, useEffect } from 'react'; + +export function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(handler); + }; + }, [value, delay]); + + return debouncedValue; +} \ No newline at end of file diff --git a/frontend/lib/react-query/hooks/search/useGlobalSearch.ts b/frontend/lib/react-query/hooks/search/useGlobalSearch.ts new file mode 100644 index 0000000..cb2f76a --- /dev/null +++ b/frontend/lib/react-query/hooks/search/useGlobalSearch.ts @@ -0,0 +1,15 @@ +import { useQuery } from '@tanstack/react-query'; +import { apiClient } from '@/lib/api-client'; + +export const useGlobalSearch = (query: string, types: string) => { + return useQuery({ + queryKey: ['globalSearch', query, types], + queryFn: async () => { + const { data } = await apiClient.get('/search', { + params: { q: query, types }, + }); + return data; + }, + enabled: !!query, + }); +}; \ No newline at end of file