diff --git a/.env.example b/.env.example index ae15676..f4ebe18 100644 --- a/.env.example +++ b/.env.example @@ -44,8 +44,9 @@ EMAIL_FROM="onboarding@resend.dev" # Just uncomment the ones you want to use and set your credentials # Uncommented ones must have their credentials set -# Google OAuth2 -# Go to https://console.developers.google.com/ to create your OAuth2 credentials +# Google OAuth2 ("Sign in with Google" — user login) +# Create OAuth 2.0 credentials at https://console.cloud.google.com/apis/credentials +# Application type = Web application, then Client ID + Client secret GOOGLE_CLIENT_ID="your-google-client-id.apps.googleusercontent.com" GOOGLE_CLIENT_SECRET="your-google-client-secret" GOOGLE_CALLBACK_URL="http://localhost:4000/auth/google/callback" @@ -70,3 +71,21 @@ MICROSOFT_CALLBACK_URL="http://localhost:4000/auth/microsoft/callback" # SLACK_CLIENT_ID="xxxxxxxxxxxxx.xxxxxxxxxxxxx" # SLACK_CLIENT_SECRET="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" # SLACK_CALLBACK_URL="http://localhost:4000/auth/slack/callback" + +# Google Cloud Storage (avatar / background uploads — optional) +# This is NOT the same as OAuth: you need a GCP service account with Storage access. +# +# 1) Bucket name (e.g. from Terraform: myproject-epitrello-uploads). +# If unset, files are saved under backend/uploads/ +# GCS_BUCKET_NAME="myproject-epitrello-uploads" +# +# 2) GCS authentication (one option is enough): +# - GCP secret: GCP_SERVICE_ACCOUNT = raw JSON content of the service account key +# (e.g. value from GCP Secret Manager or env var with pasted JSON). +# - Key file: GOOGLE_APPLICATION_CREDENTIALS = path to the service account JSON file. +# - Base64 key: GCS_SERVICE_ACCOUNT_KEY_BASE64 (for env without a file). +# - In prod (Cloud Run): leave unset; the service uses the runtime service account. +# +# GCP_SERVICE_ACCOUNT='{"type":"service_account","project_id":"...", ...}' +# GOOGLE_APPLICATION_CREDENTIALS="./backend/service-account-key.json" +# GCS_SERVICE_ACCOUNT_KEY_BASE64="eyJ0eXBlIjoi..." diff --git a/backend/package.json b/backend/package.json index 3c9041b..b20f44a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -44,6 +44,7 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.0", "cookie-parser": "^1.4.7", + "@google-cloud/storage": "^7.14.0", "dataloader": "^2.2.3", "express": "^4.21.2", "graphql": "^16.8.1", diff --git a/backend/pnpm-lock.yaml b/backend/pnpm-lock.yaml index 688573b..9c08323 100644 --- a/backend/pnpm-lock.yaml +++ b/backend/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@google-cloud/storage': + specifier: ^7.14.0 + version: 7.19.0 '@nestjs/apollo': specifier: ^12.0.9 version: 12.2.2(@apollo/server@4.12.2(graphql@16.12.0))(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/platform-express@10.4.20)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/graphql@12.2.2(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/platform-express@10.4.20)(reflect-metadata@0.1.14)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.3)(graphql@16.12.0)(reflect-metadata@0.1.14))(graphql@16.12.0) @@ -555,6 +558,22 @@ packages: resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@google-cloud/paginator@5.0.2': + resolution: {integrity: sha512-DJS3s0OVH4zFDB1PzjxAsHqJT6sKVbRwwML0ZBP9PbU7Yebtu/7SWMRzvO2J3nUi9pRNITCfu4LJeooM2w4pjg==} + engines: {node: '>=14.0.0'} + + '@google-cloud/projectify@4.0.0': + resolution: {integrity: sha512-MmaX6HeSvyPbWGwFq7mXdo0uQZLGBYCwziiLIGq5JVX+/bdI3SAq6bP98trV5eTWfLuvsMcIC1YJOF2vfteLFA==} + engines: {node: '>=14.0.0'} + + '@google-cloud/promisify@4.0.0': + resolution: {integrity: sha512-Orxzlfb9c67A15cq2JQEyVc7wEsmFBmHjZWZYQMUyJ1qivXyMwdyNOs9odi79hze+2zqdTtu1E19IM/FtqZ10g==} + engines: {node: '>=14'} + + '@google-cloud/storage@7.19.0': + resolution: {integrity: sha512-n2FjE7NAOYyshogdc7KQOl/VZb4sneqPjWouSyia9CMDdMhRX5+RIbqalNmC7LOLzuLAN89VlF2HvG8na9G+zQ==} + engines: {node: '>=14'} + '@graphql-tools/load-files@6.6.1': resolution: {integrity: sha512-nd4GOjdD68bdJkHfRepILb0gGwF63mJI7uD4oJuuf2Kzeq8LorKa6WfyxUhdMuLmZhnx10zdAlWPfwv1NOAL4Q==} peerDependencies: @@ -1075,6 +1094,10 @@ packages: '@tokenizer/token@0.3.0': resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + '@tootallnate/once@2.0.0': + resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} + engines: {node: '>= 10'} + '@tsconfig/node10@1.0.12': resolution: {integrity: sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==} @@ -1111,6 +1134,9 @@ packages: '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + '@types/caseless@0.12.5': + resolution: {integrity: sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==} + '@types/concat-stream@1.6.1': resolution: {integrity: sha512-eHE4cQPoj6ngxBZMvVf6Hw7Mh4jMW4U9lpGmS5GBPB9RYxlFg+CHaVN7ErNY4W9XfLIEn20b4VDYaIrbq0q4uA==} @@ -1236,6 +1262,9 @@ packages: '@types/range-parser@1.2.7': resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + '@types/request@2.48.13': + resolution: {integrity: sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg==} + '@types/semver@7.7.1': resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} @@ -1260,6 +1289,9 @@ packages: '@types/supertest@6.0.3': resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==} + '@types/tough-cookie@4.0.5': + resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + '@types/validator@13.15.10': resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==} @@ -1391,6 +1423,10 @@ packages: resolution: {integrity: sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + accepts@1.3.8: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} @@ -1409,6 +1445,14 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + ajv-formats@2.1.1: resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} peerDependencies: @@ -1556,6 +1600,10 @@ packages: resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} engines: {node: '>=8'} + arrify@2.0.1: + resolution: {integrity: sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==} + engines: {node: '>=8'} + asap@2.0.6: resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} @@ -1627,6 +1675,9 @@ packages: bcryptjs@2.4.3: resolution: {integrity: sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==} + bignumber.js@9.3.1: + resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} @@ -2087,6 +2138,9 @@ packages: duplexer@0.1.2: resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} + duplexify@4.1.3: + resolution: {integrity: sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==} + eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} @@ -2122,6 +2176,9 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + enhanced-resolve@5.18.3: resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==} engines: {node: '>=10.13.0'} @@ -2252,6 +2309,10 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + eventemitter2@0.4.14: resolution: {integrity: sha512-K7J4xq5xAD5jHsGM5ReWXRTFa3JRGofHiMcVgQ8PRwgWxzjHpMWCIzsmyf60+mh8KLsqYPcjUMa0AC4hd6lPyQ==} @@ -2318,6 +2379,10 @@ packages: fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fast-xml-parser@5.3.5: + resolution: {integrity: sha512-JeaA2Vm9ffQKp9VjvfzObuMCjUYAp5WDYhRYL5LrBPY/jUDlUtOvDfot0vKSkB9tuX885BDHjtw4fZadD95wnA==} + hasBin: true + fastq@1.19.1: resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} @@ -2450,10 +2515,18 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + gaxios@6.7.1: + resolution: {integrity: sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==} + engines: {node: '>=14'} + gaze@1.1.3: resolution: {integrity: sha512-BRdNm8hbWzFzWHERTrejLqwHDfS4GibPoq5wjTPIoJHoBtKGPg3xAFfxmM+9ztbXelxcf2hwQcaz1PtmFeue8g==} engines: {node: '>= 4.0.0'} + gcp-metadata@6.1.1: + resolution: {integrity: sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==} + engines: {node: '>=14'} + gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -2533,6 +2606,14 @@ packages: resolution: {integrity: sha512-OPTIfhMBh7JbBYDpa5b+Q5ptmMWKwcNcFSR/0c6t8V4f3ZAVBEsKNY37QdVqmLRYSMhOUGYrY0QhSoEpzGr/Eg==} engines: {node: '>= 0.10'} + google-auth-library@9.15.1: + resolution: {integrity: sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==} + engines: {node: '>=14'} + + google-logging-utils@0.0.2: + resolution: {integrity: sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==} + engines: {node: '>=14'} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -2650,6 +2731,10 @@ packages: engines: {node: '>=8'} hasBin: true + gtoken@7.1.0: + resolution: {integrity: sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==} + engines: {node: '>=14.0.0'} + gzip-size@5.1.1: resolution: {integrity: sha512-FNHi6mmoHvs1mxZAds4PpdCS6QG8B4C1krxJsMutgxl5t3+GlRTzzI3NEkifXx2pVsOvJdOGSmIgDhQ55FwdPA==} engines: {node: '>=6'} @@ -2697,6 +2782,9 @@ packages: hooker@0.2.3: resolution: {integrity: sha512-t+UerCsQviSymAInD01Pw+Dn/usmz1sRO+3Zk1+lx8eg+WKpD2ulcwWqHHL0+aseRBr+3+vIhiG1K1JTwaIcTA==} + html-entities@2.6.0: + resolution: {integrity: sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} @@ -2725,6 +2813,10 @@ packages: http-parser-js@0.5.10: resolution: {integrity: sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==} + http-proxy-agent@5.0.0: + resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==} + engines: {node: '>= 6'} + http-response-object@3.0.2: resolution: {integrity: sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA==} @@ -2732,6 +2824,14 @@ packages: resolution: {integrity: sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==} engines: {node: '>=10.19.0'} + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + human-signals@2.1.0: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} @@ -3079,6 +3179,9 @@ packages: engines: {node: '>=6'} hasBin: true + json-bigint@1.0.0: + resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} + json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} @@ -3118,9 +3221,15 @@ packages: jwa@1.4.2: resolution: {integrity: sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==} + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + jws@3.2.2: resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==} + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -3331,6 +3440,11 @@ packages: engines: {node: '>=4.0.0'} hasBin: true + mime@3.0.0: + resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} + engines: {node: '>=10.0.0'} + hasBin: true + mimic-fn@2.1.0: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} @@ -3860,6 +3974,10 @@ packages: resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} engines: {node: '>=8'} + retry-request@7.0.2: + resolution: {integrity: sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w==} + engines: {node: '>=14'} + retry@0.13.1: resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} engines: {node: '>= 4'} @@ -4050,6 +4168,12 @@ packages: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} + stream-events@1.0.5: + resolution: {integrity: sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==} + + stream-shift@1.0.3: + resolution: {integrity: sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==} + streamsearch@1.1.0: resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} engines: {node: '>=10.0.0'} @@ -4106,10 +4230,16 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strnum@2.1.2: + resolution: {integrity: sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==} + strtok3@10.3.4: resolution: {integrity: sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==} engines: {node: '>=18'} + stubs@3.0.0: + resolution: {integrity: sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==} + subscriptions-transport-ws@0.11.0: resolution: {integrity: sha512-8D4C6DIH5tGiAIpp5I0wD/xRlNiZAPGHygzCe7VzyzUoxHtawzjNAY9SUTXU05/EY2NMY9/9GF0ycizkXr1CWQ==} deprecated: The `subscriptions-transport-ws` package is no longer maintained. We recommend you use `graphql-ws` instead. For help migrating Apollo software to `graphql-ws`, see https://www.apollographql.com/docs/apollo-server/data/subscriptions/#switching-from-subscriptions-transport-ws For general help using `graphql-ws`, see https://github.com/enisdenjo/graphql-ws/blob/master/README.md @@ -4166,6 +4296,10 @@ packages: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} + teeny-request@9.0.0: + resolution: {integrity: sha512-resvxdc6Mgb7YEThw6G6bExlXKkv6+YbuzGg9xuXxSgxJF7Ozs+o8Y9+2R3sArdWdW8nOokoQb1yrpFB0pQK2g==} + engines: {node: '>=14'} + terser-webpack-plugin@5.3.14: resolution: {integrity: sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==} engines: {node: '>= 10.13.0'} @@ -4413,6 +4547,10 @@ packages: resolution: {integrity: sha512-d0z310fCWv5dJwnX1Y/MncBAqGMKEzlBb1AOf7z9K8ALnd0utBX/msg/fA0+sbyN1ihbMsLhrBlnl1ak7Wa0rg==} hasBin: true + uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + hasBin: true + uuid@9.0.1: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} hasBin: true @@ -5037,6 +5175,36 @@ snapshots: '@eslint/js@8.57.1': {} + '@google-cloud/paginator@5.0.2': + dependencies: + arrify: 2.0.1 + extend: 3.0.2 + + '@google-cloud/projectify@4.0.0': {} + + '@google-cloud/promisify@4.0.0': {} + + '@google-cloud/storage@7.19.0': + dependencies: + '@google-cloud/paginator': 5.0.2 + '@google-cloud/projectify': 4.0.0 + '@google-cloud/promisify': 4.0.0 + abort-controller: 3.0.0 + async-retry: 1.3.3 + duplexify: 4.1.3 + fast-xml-parser: 5.3.5 + gaxios: 6.7.1 + google-auth-library: 9.15.1 + html-entities: 2.6.0 + mime: 3.0.0 + p-limit: 3.1.0 + retry-request: 7.0.2 + teeny-request: 9.0.0 + uuid: 8.3.2 + transitivePeerDependencies: + - encoding + - supports-color + '@graphql-tools/load-files@6.6.1(graphql@16.12.0)': dependencies: globby: 11.1.0 @@ -5681,6 +5849,8 @@ snapshots: '@tokenizer/token@0.3.0': {} + '@tootallnate/once@2.0.0': {} + '@tsconfig/node10@1.0.12': {} '@tsconfig/node12@1.0.11': {} @@ -5726,6 +5896,8 @@ snapshots: '@types/connect': 3.4.38 '@types/node': 20.19.25 + '@types/caseless@0.12.5': {} + '@types/concat-stream@1.6.1': dependencies: '@types/node': 20.19.25 @@ -5883,6 +6055,13 @@ snapshots: '@types/range-parser@1.2.7': {} + '@types/request@2.48.13': + dependencies: + '@types/caseless': 0.12.5 + '@types/node': 20.19.25 + '@types/tough-cookie': 4.0.5 + form-data: 2.5.5 + '@types/semver@7.7.1': {} '@types/send@0.17.6': @@ -5919,6 +6098,8 @@ snapshots: '@types/methods': 1.1.4 '@types/superagent': 8.1.9 + '@types/tough-cookie@4.0.5': {} + '@types/validator@13.15.10': {} '@types/ws@8.18.1': @@ -6103,6 +6284,10 @@ snapshots: abbrev@2.0.0: {} + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + accepts@1.3.8: dependencies: mime-types: 2.1.35 @@ -6118,6 +6303,14 @@ snapshots: acorn@8.15.0: {} + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + agent-base@7.1.4: {} + ajv-formats@2.1.1(ajv@8.12.0): optionalDependencies: ajv: 8.12.0 @@ -6289,6 +6482,8 @@ snapshots: array-union@2.1.0: {} + arrify@2.0.1: {} + asap@2.0.6: {} async-retry@1.3.3: @@ -6380,6 +6575,8 @@ snapshots: bcryptjs@2.4.3: {} + bignumber.js@9.3.1: {} + binary-extensions@2.3.0: {} bl@4.1.0: @@ -6849,6 +7046,13 @@ snapshots: duplexer@0.1.2: {} + duplexify@4.1.3: + dependencies: + end-of-stream: 1.4.5 + inherits: 2.0.4 + readable-stream: 3.6.2 + stream-shift: 1.0.3 + eastasianwidth@0.2.0: {} ecdsa-sig-formatter@1.0.11: @@ -6876,6 +7080,10 @@ snapshots: encodeurl@2.0.0: {} + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + enhanced-resolve@5.18.3: dependencies: graceful-fs: 4.2.11 @@ -7015,6 +7223,8 @@ snapshots: etag@1.8.1: {} + event-target-shim@5.0.1: {} + eventemitter2@0.4.14: {} eventemitter3@3.1.2: {} @@ -7121,6 +7331,10 @@ snapshots: fast-uri@3.1.0: {} + fast-xml-parser@5.3.5: + dependencies: + strnum: 2.1.2 + fastq@1.19.1: dependencies: reusify: 1.1.0 @@ -7295,10 +7509,30 @@ snapshots: function-bind@1.1.2: {} + gaxios@6.7.1: + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + is-stream: 2.0.1 + node-fetch: 2.7.0 + uuid: 9.0.1 + transitivePeerDependencies: + - encoding + - supports-color + gaze@1.1.3: dependencies: globule: 1.3.4 + gcp-metadata@6.1.1: + dependencies: + gaxios: 6.7.1 + google-logging-utils: 0.0.2 + json-bigint: 1.0.0 + transitivePeerDependencies: + - encoding + - supports-color + gensync@1.0.0-beta.2: {} get-caller-file@2.0.5: {} @@ -7407,6 +7641,20 @@ snapshots: lodash: 4.17.21 minimatch: 3.0.8 + google-auth-library@9.15.1: + dependencies: + base64-js: 1.5.1 + ecdsa-sig-formatter: 1.0.11 + gaxios: 6.7.1 + gcp-metadata: 6.1.1 + gtoken: 7.1.0 + jws: 4.0.1 + transitivePeerDependencies: + - encoding + - supports-color + + google-logging-utils@0.0.2: {} + gopd@1.2.0: {} graceful-fs@4.2.11: {} @@ -7546,6 +7794,14 @@ snapshots: nopt: 3.0.6 rimraf: 3.0.2 + gtoken@7.1.0: + dependencies: + gaxios: 6.7.1 + jws: 4.0.1 + transitivePeerDependencies: + - encoding + - supports-color + gzip-size@5.1.1: dependencies: duplexer: 0.1.2 @@ -7590,6 +7846,8 @@ snapshots: hooker@0.2.3: {} + html-entities@2.6.0: {} + html-escaper@2.0.2: {} htmlparser2@8.0.2: @@ -7638,6 +7896,14 @@ snapshots: http-parser-js@0.5.10: {} + http-proxy-agent@5.0.0: + dependencies: + '@tootallnate/once': 2.0.0 + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + http-response-object@3.0.2: dependencies: '@types/node': 10.17.60 @@ -7647,6 +7913,20 @@ snapshots: quick-lru: 5.1.1 resolve-alpn: 1.2.1 + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + human-signals@2.1.0: {} iconv-lite@0.4.24: @@ -8181,6 +8461,10 @@ snapshots: jsesc@3.1.0: {} + json-bigint@1.0.0: + dependencies: + bignumber.js: 9.3.1 + json-buffer@3.0.1: {} json-parse-even-better-errors@2.3.1: {} @@ -8224,11 +8508,22 @@ snapshots: ecdsa-sig-formatter: 1.0.11 safe-buffer: 5.2.1 + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + jws@3.2.2: dependencies: jwa: 1.4.2 safe-buffer: 5.2.1 + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -8394,6 +8689,8 @@ snapshots: mime@2.6.0: {} + mime@3.0.0: {} + mimic-fn@2.1.0: {} minimatch@3.0.8: @@ -8884,6 +9181,15 @@ snapshots: onetime: 5.1.2 signal-exit: 3.0.7 + retry-request@7.0.2: + dependencies: + '@types/request': 2.48.13 + extend: 3.0.2 + teeny-request: 9.0.0 + transitivePeerDependencies: + - encoding + - supports-color + retry@0.13.1: {} reusify@1.1.0: {} @@ -9123,6 +9429,12 @@ snapshots: statuses@2.0.2: {} + stream-events@1.0.5: + dependencies: + stubs: 3.0.0 + + stream-shift@1.0.3: {} + streamsearch@1.1.0: {} string-length@4.0.2: @@ -9174,10 +9486,14 @@ snapshots: strip-json-comments@3.1.1: {} + strnum@2.1.2: {} + strtok3@10.3.4: dependencies: '@tokenizer/token': 0.3.0 + stubs@3.0.0: {} + subscriptions-transport-ws@0.11.0(graphql@16.12.0): dependencies: backo2: 1.0.2 @@ -9252,6 +9568,17 @@ snapshots: tapable@2.3.0: {} + teeny-request@9.0.0: + dependencies: + http-proxy-agent: 5.0.0 + https-proxy-agent: 5.0.1 + node-fetch: 2.7.0 + stream-events: 1.0.5 + uuid: 9.0.1 + transitivePeerDependencies: + - encoding + - supports-color + terser-webpack-plugin@5.3.14(webpack@5.97.1): dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -9481,6 +9808,8 @@ snapshots: uuid@11.0.3: {} + uuid@8.3.2: {} + uuid@9.0.1: {} v8-compile-cache-lib@3.0.1: {} diff --git a/backend/src/common/utils/sanitize.spec.ts b/backend/src/common/utils/sanitize.spec.ts new file mode 100644 index 0000000..efa0a70 --- /dev/null +++ b/backend/src/common/utils/sanitize.spec.ts @@ -0,0 +1,22 @@ +import { escapeSingleQuotes } from './sanitize'; + +describe('sanitize', () => { + describe('escapeSingleQuotes', () => { + it('should double single quotes (SQL-style)', () => { + expect(escapeSingleQuotes("it's")).toBe("it''s"); + expect(escapeSingleQuotes("'quoted'")).toBe("''quoted''"); + }); + + it('should return empty string when input is empty', () => { + expect(escapeSingleQuotes('')).toBe(''); + }); + + it('should replace all occurrences, not just the first', () => { + expect(escapeSingleQuotes("a'b'c")).toBe("a''b''c"); + }); + + it('should leave string unchanged when no single quote', () => { + expect(escapeSingleQuotes('hello')).toBe('hello'); + }); + }); +}); diff --git a/backend/src/modules/upload/storage.service.spec.ts b/backend/src/modules/upload/storage.service.spec.ts new file mode 100644 index 0000000..b4569ad --- /dev/null +++ b/backend/src/modules/upload/storage.service.spec.ts @@ -0,0 +1,194 @@ +import { Test } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { StorageService } from './storage.service'; +import { Storage } from '@google-cloud/storage'; + +const mockBucket = { + file: jest.fn().mockReturnValue({ + save: jest.fn().mockResolvedValue(undefined), + }), +}; +const mockStorageInstance = { + bucket: jest.fn().mockReturnValue(mockBucket), +}; + +jest.mock('@google-cloud/storage', () => ({ + Storage: jest.fn().mockImplementation(() => mockStorageInstance), +})); + +describe('StorageService', () => { + let configGet: jest.Mock; + + const createService = async (config: Record) => { + configGet = jest.fn((key: string) => config[key]); + const module = await Test.createTestingModule({ + providers: [ + StorageService, + { provide: ConfigService, useValue: { get: configGet } }, + ], + }).compile(); + return module.get(StorageService); + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('constructor', () => { + it('should disable GCS when GCS_BUCKET_NAME is not set', async () => { + const service = await createService({ GCS_BUCKET_NAME: undefined }); + expect(service.isGcsEnabled()).toBe(false); + }); + + it('should disable GCS when GCS_BUCKET_NAME is empty string', async () => { + const service = await createService({ GCS_BUCKET_NAME: '' }); + expect(service.isGcsEnabled()).toBe(false); + }); + + it('should enable GCS with bucket only (default Storage)', async () => { + const service = await createService({ GCS_BUCKET_NAME: 'my-bucket' }); + expect(service.isGcsEnabled()).toBe(true); + expect(Storage).toHaveBeenCalledWith(); + }); + + it('should enable GCS with GCP_SERVICE_ACCOUNT valid JSON', async () => { + const key = JSON.stringify({ + type: 'service_account', + project_id: 'my-project', + private_key_id: 'key-id', + private_key: '-----BEGIN PRIVATE KEY-----\nxxx\n-----END PRIVATE KEY-----\n', + client_email: 'sa@my-project.iam.gserviceaccount.com', + client_id: '123', + }); + const service = await createService({ + GCS_BUCKET_NAME: 'my-bucket', + GCP_SERVICE_ACCOUNT: key, + }); + expect(service.isGcsEnabled()).toBe(true); + expect(Storage).toHaveBeenCalledWith({ + credentials: JSON.parse(key), + projectId: 'my-project', + }); + }); + + it('should fallback to default Storage when GCP_SERVICE_ACCOUNT is invalid JSON', async () => { + const service = await createService({ + GCS_BUCKET_NAME: 'my-bucket', + GCP_SERVICE_ACCOUNT: 'not-valid-json', + }); + expect(service.isGcsEnabled()).toBe(true); + expect(Storage).toHaveBeenCalledWith(); + }); + + it('should fallback to default Storage when GCP_SERVICE_ACCOUNT has no project_id', async () => { + const service = await createService({ + GCS_BUCKET_NAME: 'my-bucket', + GCP_SERVICE_ACCOUNT: JSON.stringify({ type: 'service_account' }), + }); + expect(service.isGcsEnabled()).toBe(true); + expect(Storage).toHaveBeenCalledWith(); + }); + + it('should enable GCS with GCS_SERVICE_ACCOUNT_KEY_BASE64 when GCP_SERVICE_ACCOUNT not set', async () => { + const key = { project_id: 'proj-base64', type: 'service_account' }; + const base64 = Buffer.from(JSON.stringify(key)).toString('base64'); + const service = await createService({ + GCS_BUCKET_NAME: 'my-bucket', + GCS_SERVICE_ACCOUNT_KEY_BASE64: base64, + }); + expect(service.isGcsEnabled()).toBe(true); + expect(Storage).toHaveBeenCalledWith({ + credentials: key, + projectId: 'proj-base64', + }); + }); + + it('should prefer GCP_SERVICE_ACCOUNT over GCS_SERVICE_ACCOUNT_KEY_BASE64', async () => { + const gcpKey = JSON.stringify({ project_id: 'from-gcp', type: 'service_account' }); + const base64Key = Buffer.from(JSON.stringify({ project_id: 'from-base64' })).toString('base64'); + const service = await createService({ + GCS_BUCKET_NAME: 'my-bucket', + GCP_SERVICE_ACCOUNT: gcpKey, + GCS_SERVICE_ACCOUNT_KEY_BASE64: base64Key, + }); + expect(service.isGcsEnabled()).toBe(true); + expect(Storage).toHaveBeenCalledWith({ + credentials: JSON.parse(gcpKey), + projectId: 'from-gcp', + }); + }); + + it('should fallback to default Storage when GCS_SERVICE_ACCOUNT_KEY_BASE64 is invalid', async () => { + const service = await createService({ + GCS_BUCKET_NAME: 'my-bucket', + GCS_SERVICE_ACCOUNT_KEY_BASE64: 'not-valid-base64-json!!!', + }); + expect(service.isGcsEnabled()).toBe(true); + expect(Storage).toHaveBeenCalledWith(); + }); + + it('should disable GCS when Storage constructor throws with credentials', async () => { + (Storage as unknown as jest.Mock).mockImplementationOnce(() => { + throw new Error('Auth failed'); + }); + const key = JSON.stringify({ project_id: 'p', type: 'service_account' }); + const service = await createService({ + GCS_BUCKET_NAME: 'my-bucket', + GCP_SERVICE_ACCOUNT: key, + }); + expect(service.isGcsEnabled()).toBe(false); + }); + }); + + describe('isGcsEnabled', () => { + it('should return true when bucket and storage are set', async () => { + const service = await createService({ GCS_BUCKET_NAME: 'b' }); + expect(service.isGcsEnabled()).toBe(true); + }); + + it('should return false when bucket is not set', async () => { + const service = await createService({}); + expect(service.isGcsEnabled()).toBe(false); + }); + }); + + describe('uploadToGcs', () => { + it('should throw when GCS is not configured', async () => { + const service = await createService({}); + await expect( + service.uploadToGcs(Buffer.from('x'), 'avatars', 'f.jpg', 'image/jpeg'), + ).rejects.toThrow('GCS is not configured. Set GCS_BUCKET_NAME.'); + }); + + it('should upload and return public URL when GCS is enabled', async () => { + const service = await createService({ GCS_BUCKET_NAME: 'my-bucket' }); + const buffer = Buffer.from('image-data'); + const url = await service.uploadToGcs( + buffer, + 'backgrounds', + 'bg-123.png', + 'image/png', + ); + expect(url).toBe('https://storage.googleapis.com/my-bucket/backgrounds/bg-123.png'); + expect(mockStorageInstance.bucket).toHaveBeenCalledWith('my-bucket'); + expect(mockBucket.file).toHaveBeenCalledWith('backgrounds/bg-123.png'); + const fileInstance = mockBucket.file(); + expect(fileInstance.save).toHaveBeenCalledWith(buffer, { + contentType: 'image/png', + metadata: { cacheControl: 'public, max-age=31536000' }, + }); + }); + + it('should upload to avatars folder', async () => { + const service = await createService({ GCS_BUCKET_NAME: 'b' }); + await service.uploadToGcs(Buffer.from('x'), 'avatars', 'u1.jpg', 'image/jpeg'); + expect(mockBucket.file).toHaveBeenCalledWith('avatars/u1.jpg'); + }); + + it('should upload to attachments folder', async () => { + const service = await createService({ GCS_BUCKET_NAME: 'b' }); + await service.uploadToGcs(Buffer.from('x'), 'attachments', 'a1.pdf', 'application/pdf'); + expect(mockBucket.file).toHaveBeenCalledWith('attachments/a1.pdf'); + }); + }); +}); diff --git a/backend/src/modules/upload/storage.service.ts b/backend/src/modules/upload/storage.service.ts new file mode 100644 index 0000000..78a1436 --- /dev/null +++ b/backend/src/modules/upload/storage.service.ts @@ -0,0 +1,96 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Storage } from '@google-cloud/storage'; + +export type UploadFolder = 'avatars' | 'backgrounds' | 'attachments'; + +@Injectable() +export class StorageService { + private readonly logger = new Logger(StorageService.name); + private readonly bucketName: string | null; + private readonly storage: Storage | null; + + constructor(private config: ConfigService) { + const bucket = this.config.get('GCS_BUCKET_NAME'); + if (bucket) { + this.bucketName = bucket; + const gcpSecret = this.config.get('GCP_SERVICE_ACCOUNT'); + const keyBase64 = this.config.get('GCS_SERVICE_ACCOUNT_KEY_BASE64'); + let credentials: { project_id?: string } | null = null; + if (gcpSecret?.trim()) { + try { + const parsed = JSON.parse(gcpSecret.trim()) as { project_id?: string }; + if (parsed.project_id) credentials = parsed; + } catch { + this.logger.warn( + 'GCP_SERVICE_ACCOUNT is set but invalid JSON. Using GOOGLE_APPLICATION_CREDENTIALS or default.', + ); + } + } + if (!credentials && keyBase64) { + try { + const keyJson = Buffer.from(keyBase64, 'base64').toString('utf8'); + credentials = JSON.parse(keyJson) as { project_id?: string }; + } catch { + this.logger.warn( + 'GCS_SERVICE_ACCOUNT_KEY_BASE64 is set but invalid. Using GOOGLE_APPLICATION_CREDENTIALS or default.', + ); + } + } + if (credentials) { + try { + this.storage = new Storage({ + credentials, + projectId: credentials.project_id, + }); + this.logger.log(`Upload: GCS enabled (bucket: ${bucket})`); + } catch (err) { + this.bucketName = null; + this.storage = null; + this.logger.warn( + `Upload: GCS disabled — failed to create Storage client. Files will be saved to backend/uploads/. Error: ${err instanceof Error ? err.message : String(err)}`, + ); + return; + } + } else { + this.storage = new Storage(); + this.logger.log( + `Upload: GCS enabled (bucket: ${bucket}), using GOOGLE_APPLICATION_CREDENTIALS or default credentials`, + ); + } + } else { + this.bucketName = null; + this.storage = null; + this.logger.log( + 'Upload: GCS disabled — GCS_BUCKET_NAME not set. Files will be saved to backend/uploads/', + ); + } + } + + isGcsEnabled(): boolean { + return this.bucketName != null && this.storage != null; + } + + /** + * Upload a file buffer to GCS and return the public URL. + * Throws if GCS is not configured. + */ + async uploadToGcs( + buffer: Buffer, + folder: UploadFolder, + filename: string, + mimetype: string, + ): Promise { + if (!this.bucketName || !this.storage) { + throw new Error('GCS is not configured. Set GCS_BUCKET_NAME.'); + } + const path = `${folder}/${filename}`; + const bucket = this.storage.bucket(this.bucketName); + const file = bucket.file(path); + await file.save(buffer, { + contentType: mimetype, + metadata: { cacheControl: 'public, max-age=31536000' }, + }); + return `https://storage.googleapis.com/${this.bucketName}/${path}`; + } +} diff --git a/backend/src/modules/upload/upload.controller.spec.ts b/backend/src/modules/upload/upload.controller.spec.ts index 7eeb3db..5cfcaae 100644 --- a/backend/src/modules/upload/upload.controller.spec.ts +++ b/backend/src/modules/upload/upload.controller.spec.ts @@ -1,10 +1,16 @@ import { Test, TestingModule } from '@nestjs/testing'; import { BadRequestException, UnauthorizedException } from '@nestjs/common'; import { UploadController } from './upload.controller'; +import { StorageService } from './storage.service'; describe('UploadController', () => { let controller: UploadController; + const mockStorageService = { + isGcsEnabled: jest.fn().mockReturnValue(false), + uploadToGcs: jest.fn(), + }; + const mockRequest = (overrides: { user?: { id: string }; protocol?: string; @@ -26,16 +32,15 @@ describe('UploadController', () => { encoding: '7bit', mimetype: 'image/jpeg', size: 1024, - destination: '/tmp/uploads/avatars', - filename: 'user-1-1234567890.jpg', - path: '/tmp/uploads/avatars/user-1-1234567890.jpg', buffer: Buffer.from('fake'), ...overrides, }) as Express.Multer.File; beforeEach(async () => { + mockStorageService.isGcsEnabled.mockReturnValue(false); const module: TestingModule = await Test.createTestingModule({ controllers: [UploadController], + providers: [{ provide: StorageService, useValue: mockStorageService }], }).compile(); controller = module.get(UploadController); @@ -53,23 +58,21 @@ describe('UploadController', () => { describe('uploadAvatar', () => { it('should return url when user is authenticated and file is provided', async () => { const req = mockRequest({ user: { id: 'user-1' } }); - const file = mockFile({ filename: 'user-1-123.jpg' }); + const file = mockFile(); const result = await controller.uploadAvatar(file, req); - expect(result).toEqual({ - url: 'http://localhost:4000/uploads/avatars/user-1-123.jpg', - }); + expect(result.url).toMatch(/^http:\/\/localhost:4000\/uploads\/avatars\/user-1-\d+\.jpg$/); }); it('should use API_PUBLIC_URL when set', async () => { process.env.API_PUBLIC_URL = 'https://api.example.com'; const req = mockRequest({ user: { id: 'user-1' } }); - const file = mockFile({ filename: 'user-1-456.png' }); + const file = mockFile(); const result = await controller.uploadAvatar(file, req); - expect(result.url).toBe('https://api.example.com/uploads/avatars/user-1-456.png'); + expect(result.url).toMatch(/^https:\/\/api\.example\.com\/uploads\/avatars\/user-1-\d+\.\w+$/); }); it('should throw UnauthorizedException when user is not authenticated', async () => { @@ -96,17 +99,79 @@ describe('UploadController', () => { ); }); - it('should build url with req protocol and host when API_PUBLIC_URL is not set', async () => { - const req = mockRequest({ - user: { id: 'user-1' }, - protocol: 'https', - host: 'app.example.com', - }); - const file = mockFile({ filename: 'user-1-789.webp' }); + it('should use GCS when enabled and return GCS URL', async () => { + mockStorageService.isGcsEnabled.mockReturnValue(true); + mockStorageService.uploadToGcs.mockResolvedValue( + 'https://storage.googleapis.com/my-bucket/avatars/user-1-123.jpg', + ); + const req = mockRequest({ user: { id: 'user-1' } }); + const file = mockFile(); const result = await controller.uploadAvatar(file, req); - expect(result.url).toBe('https://app.example.com/uploads/avatars/user-1-789.webp'); + expect(result.url).toBe('https://storage.googleapis.com/my-bucket/avatars/user-1-123.jpg'); + expect(mockStorageService.uploadToGcs).toHaveBeenCalledWith( + file.buffer, + 'avatars', + expect.stringMatching(/^user-1-\d+\.jpg$/), + 'image/jpeg', + ); + }); + }); + + describe('uploadBackground', () => { + it('should return url when authenticated and file provided', async () => { + const req = mockRequest({ user: { id: 'user-1' } }); + const file = mockFile({ fieldname: 'background', mimetype: 'image/png' }); + + const result = await controller.uploadBackground(file, req); + + expect(result.url).toMatch(/^http:\/\/localhost:4000\/uploads\/backgrounds\/background-\d+-[a-z0-9]+\.png$/); + }); + + it('should use API_PUBLIC_URL for background when set (local upload)', async () => { + process.env.API_PUBLIC_URL = 'https://api.example.com'; + const req = mockRequest({ user: { id: 'user-1' } }); + const file = mockFile({ fieldname: 'background', mimetype: 'image/webp' }); + + const result = await controller.uploadBackground(file, req); + + expect(result.url).toMatch(/^https:\/\/api\.example\.com\/uploads\/backgrounds\/background-\d+-[a-z0-9]+\.webp$/); + }); + + it('should throw UnauthorizedException when not authenticated', async () => { + const req = mockRequest({ user: undefined }); + const file = mockFile({ fieldname: 'background' }); + + await expect(controller.uploadBackground(file, req)).rejects.toThrow(UnauthorizedException); + }); + + it('should throw BadRequestException when no file uploaded', async () => { + const req = mockRequest({ user: { id: 'user-1' } }); + + await expect(controller.uploadBackground(undefined, req)).rejects.toThrow(BadRequestException); + await expect(controller.uploadBackground(undefined, req)).rejects.toThrow( + 'No file uploaded. Use form field "background".', + ); + }); + + it('should use GCS when enabled and return GCS URL', async () => { + mockStorageService.isGcsEnabled.mockReturnValue(true); + mockStorageService.uploadToGcs.mockResolvedValue( + 'https://storage.googleapis.com/my-bucket/backgrounds/background-456-abc123.png', + ); + const req = mockRequest({ user: { id: 'user-1' } }); + const file = mockFile({ fieldname: 'background', mimetype: 'image/png' }); + + const result = await controller.uploadBackground(file, req); + + expect(result.url).toBe('https://storage.googleapis.com/my-bucket/backgrounds/background-456-abc123.png'); + expect(mockStorageService.uploadToGcs).toHaveBeenCalledWith( + file.buffer, + 'backgrounds', + expect.stringMatching(/^background-\d+-[a-z0-9]+\.png$/), + 'image/png', + ); }); }); }); diff --git a/backend/src/modules/upload/upload.controller.ts b/backend/src/modules/upload/upload.controller.ts index a75fed9..5c9ca05 100644 --- a/backend/src/modules/upload/upload.controller.ts +++ b/backend/src/modules/upload/upload.controller.ts @@ -8,13 +8,14 @@ import { UnauthorizedException, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; -import { diskStorage } from 'multer'; +import { memoryStorage } from 'multer'; import { join } from 'path'; -import { existsSync, mkdirSync } from 'fs'; +import { existsSync, mkdirSync, writeFileSync } from 'fs'; import { Request } from 'express'; +import { StorageService } from './storage.service'; const AVATARS_DIR = 'uploads/avatars'; -const MAX_SIZE = 2 * 1024 * 1024; // 2 MB +const BACKGROUNDS_DIR = 'uploads/backgrounds'; const ALLOWED_MIMES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; function getExtension(mimetype: string): string { @@ -28,38 +29,27 @@ function getExtension(mimetype: string): string { } /** - * Upload controller: saves files and returns public URLs. - * Avatar is only applied to the user when they save the profile form (updateUser). + * Upload controller: saves files to Google Cloud Storage (or disk when GCS not configured) + * and returns public URLs. Used for avatar, board/card background images. */ @Controller('api/upload') export class UploadController { + constructor(private readonly storage: StorageService) {} + @Post('avatar') @UseInterceptors( FileInterceptor('avatar', { - limits: { fileSize: MAX_SIZE }, + storage: memoryStorage(), fileFilter: (_req, file, cb) => { if (!ALLOWED_MIMES.includes(file.mimetype)) { - cb(new BadRequestException('Invalid file type. Use JPEG, PNG, GIF or WebP.'), false); + cb( + new BadRequestException('Invalid file type. Use JPEG, PNG, GIF or WebP.'), + false, + ); return; } cb(null, true); }, - storage: diskStorage({ - destination: (_req, _file, cb) => { - const dest = join(process.cwd(), AVATARS_DIR); - if (!existsSync(dest)) { - mkdirSync(dest, { recursive: true }); - } - cb(null, dest); - }, - filename: (req, file, cb) => { - const user = (req as Request & { user?: { id: string } }).user; - const userId = user?.id ?? 'anon'; - const ext = getExtension(file.mimetype); - const name = `${userId}-${Date.now()}.${ext}`; - cb(null, name); - }, - }), }), ) async uploadAvatar( @@ -73,9 +63,72 @@ export class UploadController { if (!file) { throw new BadRequestException('No file uploaded. Use form field "avatar".'); } - const baseUrl = process.env.API_PUBLIC_URL ?? `${req.protocol}://${req.get('host')}`; - const relativePath = `/${AVATARS_DIR}/${file.filename}`; - const url = `${baseUrl}${relativePath}`; - return { url }; + const ext = getExtension(file.mimetype); + const filename = `${user.id}-${Date.now()}.${ext}`; + + if (this.storage.isGcsEnabled()) { + const url = await this.storage.uploadToGcs( + file.buffer, + 'avatars', + filename, + file.mimetype, + ); + return { url }; + } + + const dest = join(process.cwd(), AVATARS_DIR); + if (!existsSync(dest)) mkdirSync(dest, { recursive: true }); + const filepath = join(dest, filename); + writeFileSync(filepath, file.buffer); + const baseUrl = + process.env.API_PUBLIC_URL ?? `${req.protocol}://${req.get('host')}`; + return { url: `${baseUrl}/${AVATARS_DIR}/${filename}` }; + } + + @Post('background') + @UseInterceptors( + FileInterceptor('background', { + storage: memoryStorage(), + fileFilter: (_req, file, cb) => { + if (!ALLOWED_MIMES.includes(file.mimetype)) { + cb( + new BadRequestException('Invalid file type. Use JPEG, PNG, GIF or WebP.'), + false, + ); + return; + } + cb(null, true); + }, + }), + ) + async uploadBackground( + @UploadedFile() file: Express.Multer.File | undefined, + @Req() req: Request & { user?: { id: string } }, + ): Promise<{ url: string }> { + if (!req.user?.id) { + throw new UnauthorizedException('Authentication required'); + } + if (!file) { + throw new BadRequestException('No file uploaded. Use form field "background".'); + } + const ext = getExtension(file.mimetype); + const filename = `background-${Date.now()}-${Math.random().toString(36).slice(2, 10)}.${ext}`; + + if (this.storage.isGcsEnabled()) { + const url = await this.storage.uploadToGcs( + file.buffer, + 'backgrounds', + filename, + file.mimetype, + ); + return { url }; + } + + const dest = join(process.cwd(), BACKGROUNDS_DIR); + if (!existsSync(dest)) mkdirSync(dest, { recursive: true }); + writeFileSync(join(dest, filename), file.buffer); + const baseUrl = + process.env.API_PUBLIC_URL ?? `${req.protocol}://${req.get('host')}`; + return { url: `${baseUrl}/${BACKGROUNDS_DIR}/${filename}` }; } } diff --git a/backend/src/modules/upload/upload.module.ts b/backend/src/modules/upload/upload.module.ts index b002ca9..05f9187 100644 --- a/backend/src/modules/upload/upload.module.ts +++ b/backend/src/modules/upload/upload.module.ts @@ -1,7 +1,9 @@ import { Module } from '@nestjs/common'; import { UploadController } from './upload.controller'; +import { StorageService } from './storage.service'; @Module({ controllers: [UploadController], + providers: [StorageService], }) export class UploadModule {} diff --git a/frontend/app/boards/[id]/components/BoardHeader/ChangeBackgroundDialog.tsx b/frontend/app/boards/[id]/components/BoardHeader/ChangeBackgroundDialog.tsx index 91f7a19..a4ab622 100644 --- a/frontend/app/boards/[id]/components/BoardHeader/ChangeBackgroundDialog.tsx +++ b/frontend/app/boards/[id]/components/BoardHeader/ChangeBackgroundDialog.tsx @@ -14,6 +14,7 @@ import { Label } from '@/components/ui/label'; import Image from 'next/image'; import { Image as ImageIcon, X } from 'lucide-react'; import { updateBoard } from '@/lib/actions/boards'; +import { uploadBackground } from '@/lib/actions/users'; import { toast } from '@/lib/toast'; import { BACKGROUND_COLORS } from '@/components/CardModal/constants'; import type { Board } from '../../types'; @@ -74,26 +75,25 @@ export function ChangeBackgroundDialog({ } }, [board, open]); - const handleImageUpload = (e: React.ChangeEvent) => { + const handleImageUpload = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; if (!file.type.startsWith('image/')) { - toast.error('Please select an image file'); + toast.error('Please select an image file (JPEG, PNG, GIF or WebP)'); return; } - const reader = new FileReader(); - reader.onload = (event) => { - const base64 = event.target?.result as string; - if (base64) { - saveBoardBackground(base64); - } - }; - reader.onerror = () => { - toast.error('Failed to read image file'); - }; - reader.readAsDataURL(file); + setUpdating(true); + try { + const { url } = await uploadBackground(file); + await saveBoardBackground(url); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to upload image'); + } finally { + setUpdating(false); + e.target.value = ''; + } }; const saveBoardBackground = async (url: string) => { diff --git a/frontend/app/boards/[id]/page.tsx b/frontend/app/boards/[id]/page.tsx index 00ce033..b253cbd 100644 --- a/frontend/app/boards/[id]/page.tsx +++ b/frontend/app/boards/[id]/page.tsx @@ -153,7 +153,7 @@ export default function BoardPage({ isImageBackground ? { backgroundImage: `url(${board.background})`, - backgroundSize: 'contain', + backgroundSize: 'cover', backgroundPosition: 'center', backgroundRepeat: 'no-repeat', } diff --git a/frontend/app/dashboard/page.tsx b/frontend/app/dashboard/page.tsx index c8ec6ec..889a7f8 100644 --- a/frontend/app/dashboard/page.tsx +++ b/frontend/app/dashboard/page.tsx @@ -8,7 +8,28 @@ import { Visibility, } from '@/lib/actions/boards'; import Image from 'next/image'; -import { AlertTriangle, LayoutGrid } from 'lucide-react'; +import { + AlertTriangle, + LayoutGrid, + BarChart3, + PieChart as PieChartIcon, +} from 'lucide-react'; +import { + ChartContainer, + ChartTooltip, + ChartTooltipContent, + type ChartConfig, +} from '@/components/ui/chart'; +import { + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + PieChart, + Pie, + Cell, +} from 'recharts'; import { Button } from '@/components/ui/button'; import { Empty, @@ -75,7 +96,7 @@ export default function DashboardPage() { >({}); const [newBoardVisibilityByWorkspace, setNewBoardVisibilityByWorkspace] = useState>( - {} + {}, ); const [deleteConfirm, setDeleteConfirm] = useState<{ show: boolean; @@ -120,11 +141,46 @@ export default function DashboardPage() { return m; }, [workspaces, boardQueries]); + const chartData = useMemo(() => { + const barData = workspaces.map((ws) => { + const br = boardResultsByWsId[ws.id]; + const list = (br?.data ?? []) as unknown[]; + return { + workspace: ws.title, + boards: list.length, + fill: 'var(--trello-blue)', + }; + }); + const pieData = barData + .map((d) => ({ + name: d.workspace, + value: d.boards, + fill: 'var(--trello-blue)', + })) + .filter((d) => d.value > 0); + return { barData, pieData }; + }, [workspaces, boardResultsByWsId]); + + const barChartConfig = useMemo( + () => ({ + boards: { label: 'Boards', color: 'var(--trello-blue)' }, + workspace: { label: 'Workspace' }, + }), + [], + ); + + const pieChartConfig = useMemo( + () => ({ + boards: { label: 'Boards' }, + }), + [], + ); + const createBoard = async ( workspaceId?: string, name?: string, desc?: string, - visibility?: 'personal' | 'workspace' | 'public' + visibility?: 'personal' | 'workspace' | 'public', ) => { const boardName = (name ?? newBoardName).trim(); if (!boardName) return; @@ -159,7 +215,7 @@ export default function DashboardPage() { queryClient.setQueryData( workspaceBoardsQueryKey(wsId), (old: { id: string }[] | undefined) => - (old || []).filter((b) => b.id !== boardId) + (old || []).filter((b) => b.id !== boardId), ); setFeedback(`Board "${deleteConfirm.boardName}" has been deleted`); setTimeout(() => setFeedback(null), 3000); @@ -193,6 +249,88 @@ export default function DashboardPage() {
+ {workspaces.length > 0 && ( +
+

Overview

+
+
+
+ + + Boards per workspace + +
+ + + + + + + } + cursor={{ fill: 'transparent' }} + /> + + + +
+
+
+ + + Board distribution + +
+ {chartData.pieData.length > 0 ? ( + + + + } + /> + + `${name} ${(percent * 100).toFixed(0)}%` + } + > + {chartData.pieData.map((_, i) => ( + + ))} + + + + ) : ( +
+ No boards to display in the chart +
+ )} +
+
+
+ )} +

Workspaces

@@ -305,7 +443,7 @@ export default function DashboardPage() { ws.id, newBoardNameByWorkspace[ws.id], newBoardDescByWorkspace[ws.id], - newBoardVisibilityByWorkspace[ws.id] + newBoardVisibilityByWorkspace[ws.id], ); setNewBoardNameByWorkspace((s) => ({ ...s, @@ -421,7 +559,7 @@ export default function DashboardPage() { src={board.background as string} alt={board.name} fill - className='object-contain' + className='object-cover' unoptimized /> )} diff --git a/frontend/components/BoardView.tsx b/frontend/components/BoardView.tsx index 50ca94a..a99a299 100644 --- a/frontend/components/BoardView.tsx +++ b/frontend/components/BoardView.tsx @@ -378,7 +378,7 @@ function AddListInline() { ref={buttonRef} onClick={openInput} variant='secondary' - className='w-full justify-start hover:bg-trello-blue-hover' + className='w-full justify-start bg-trello-blue hover:bg-trello-blue-hover' aria-label='Add another list' > + Add another list diff --git a/frontend/components/CardItem.tsx b/frontend/components/CardItem.tsx index c442a89..09bbb17 100644 --- a/frontend/components/CardItem.tsx +++ b/frontend/components/CardItem.tsx @@ -172,10 +172,10 @@ export default function CardItem({ onDragOver={handleDragOver} onMouseEnter={() => setIsHovering(true)} onMouseLeave={() => setIsHovering(false)} - className={`bg-secondary dark:bg-card border rounded-lg select-none transition-all duration-200 overflow-hidden ${ + className={`bg-secondary dark:bg-card rounded-lg select-none transition-all duration-200 overflow-hidden ${ card.id.startsWith('temp-') ? 'opacity-60 cursor-not-allowed border-accent' - : `hover:cursor-pointer border-accent hover:border-blue-500 ${ + : `hover:cursor-pointer border-accent hover:border hover:border-blue-500 ${ isDragging ? 'opacity-40' : '' }` } ${localCompleted ? 'opacity-70' : ''}`} diff --git a/frontend/components/CardModal.tsx b/frontend/components/CardModal.tsx index fe3dd86..d338a6c 100644 --- a/frontend/components/CardModal.tsx +++ b/frontend/components/CardModal.tsx @@ -78,6 +78,7 @@ import { unassignMemberFromCard, updateCard, } from '@/lib/actions/cards'; +import { uploadBackground } from '@/lib/actions/users'; import { createChecklist as createChecklistAPI, deleteChecklist as deleteChecklistAPI, @@ -1034,27 +1035,24 @@ export default function CardModal({ } }; - const handleImageUpload = (e: React.ChangeEvent) => { + const handleImageUpload = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; if (!file.type.startsWith('image/')) { - toast.error('Please select an image file'); + toast.error('Please select an image file (JPEG, PNG, GIF or WebP)'); return; } - const reader = new FileReader(); - reader.onload = (event) => { - const base64 = event.target?.result as string; - if (base64) { - saveBackground(base64); - setHeaderBackground(null); - } - }; - reader.onerror = () => { - toast.error('Failed to read image file'); - }; - reader.readAsDataURL(file); + try { + const { url } = await uploadBackground(file); + await saveBackground(url); + setHeaderBackground(null); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to upload image'); + } finally { + e.target.value = ''; + } }; const addComment = async () => { diff --git a/frontend/components/CreateBoardModal.tsx b/frontend/components/CreateBoardModal.tsx index 005b175..7a4ef91 100644 --- a/frontend/components/CreateBoardModal.tsx +++ b/frontend/components/CreateBoardModal.tsx @@ -22,7 +22,38 @@ import { } from '@/components/ui/dialog'; import { useWorkspacesQuery } from '@/lib/queries/workspaces'; import { getBoardTemplates, type BoardTemplate } from '@/lib/actions/boards'; -import { LayoutGrid, List } from 'lucide-react'; +import { getTemplates } from '@/lib/actions/templates'; +import Link from 'next/link'; +import { LayoutGrid, LayoutList, ArrowLeft, ChevronDown } from 'lucide-react'; +import { ScrollArea } from '@/components/ui/scroll-area'; + +/** All 4 predefined templates (fallback when API fails). */ +const FALLBACK_PREDEFINED_TEMPLATES: BoardTemplate[] = [ + { + id: 'blank', + name: 'Blank', + description: 'Empty board with To Do, Doing, Done', + listTitles: ['To Do', 'Doing', 'Done'], + }, + { + id: 'kanban', + name: 'Kanban', + description: 'Classic Kanban: To Do, In Progress, Done', + listTitles: ['To Do', 'In Progress', 'Done'], + }, + { + id: 'sprint', + name: 'Sprint', + description: 'Agile sprint: Backlog, To Do, In Progress, In Review, Done', + listTitles: ['Backlog', 'To Do', 'In Progress', 'In Review', 'Done'], + }, + { + id: 'project', + name: 'Project', + description: 'Project tracking: To Do, In Progress, Blocked, Done', + listTitles: ['To Do', 'In Progress', 'Blocked', 'Done'], + }, +]; const BOARD_COLORS = [ { @@ -62,6 +93,8 @@ export default function CreateBoardModal({ onClose, onCreate, initialTemplateId, + initialStep, + initialUseTemplate, }: { open: boolean; onClose: () => void; @@ -74,7 +107,14 @@ export default function CreateBoardModal({ }) => void; /** When opening from the template gallery, preselect this template. */ initialTemplateId?: string; + /** When set, skip the choice screen and open directly on the form. */ + initialStep?: 'choice' | 'form'; + /** When initialStep is 'form', use template selection. */ + initialUseTemplate?: boolean; }) { + type Step = 'choice' | 'templatePicker' | 'form'; + const [step, setStep] = useState('choice'); + const [useTemplate, setUseTemplate] = useState(false); const [name, setName] = useState(''); const [selectedColor, setSelectedColor] = useState( BOARD_COLORS[1].value, @@ -91,28 +131,54 @@ export default function CreateBoardModal({ const effectiveWorkspaceId = workspaceId ?? workspaces[0]?.id; useEffect(() => { - if (open) { - getBoardTemplates() - .then(setTemplates) - .catch(() => - setTemplates([ - { - id: 'blank', - name: 'Blank', - description: 'Empty board with To Do, Doing, Done', - listTitles: ['To Do', 'Doing', 'Done'], - }, - ]) - ); - } - }, [open]); + if (!open) return; + let cancelled = false; + const predefinedPromise = getBoardTemplates().catch( + () => FALLBACK_PREDEFINED_TEMPLATES, + ); + const customPromise = effectiveWorkspaceId + ? getTemplates(effectiveWorkspaceId) + .then((list) => + list.map((t) => ({ + id: t.id, + name: t.name, + description: t.description ?? '', + listTitles: (t.lists ?? []).map((l) => l.title), + })), + ) + .catch(() => []) + : Promise.resolve([]); + + Promise.all([predefinedPromise, customPromise]).then( + ([predefined, custom]) => { + if (cancelled) return; + setTemplates([...predefined, ...custom]); + }, + ); + return () => { + cancelled = true; + }; + }, [open, effectiveWorkspaceId]); useEffect(() => { - if (open && initialTemplateId) { - queueMicrotask(() => setSelectedTemplateId(initialTemplateId)); - } + if (!open || !initialTemplateId) return; + const id = requestAnimationFrame(() => { + setUseTemplate(true); + setSelectedTemplateId(initialTemplateId); + setStep('form'); + }); + return () => cancelAnimationFrame(id); }, [open, initialTemplateId]); + useEffect(() => { + if (!open || initialStep !== 'form') return; + const id = requestAnimationFrame(() => { + setUseTemplate(!!initialUseTemplate); + setStep(initialUseTemplate ? 'templatePicker' : 'form'); + }); + return () => cancelAnimationFrame(id); + }, [open, initialStep, initialUseTemplate]); + const submit = (e?: React.FormEvent) => { if (e) e.preventDefault(); if (!name.trim()) { @@ -128,11 +194,13 @@ export default function CreateBoardModal({ workspaceId: effectiveWorkspaceId, visibility, background: selectedColor, - templateId: selectedTemplateId || undefined, + templateId: useTemplate ? selectedTemplateId || undefined : undefined, }); setName(''); setSelectedColor(BOARD_COLORS[1].value); setSelectedTemplateId('blank'); + setStep('choice'); + setUseTemplate(false); onClose(); }; @@ -142,156 +210,291 @@ export default function CreateBoardModal({ onOpenChange={(isOpen) => { if (!isOpen) { setWorkspaceId(undefined); + setStep('choice'); + setUseTemplate(false); onClose(); } }} > - - - Create a new board - - Create a new board for your workspace - - -
-
- - setName(e.target.value)} - placeholder='Board name' - /> + + {step === 'choice' ? ( + <> + + Create a new board + + Choose how you want to get started + + +
+ + +
+ + ) : step === 'templatePicker' ? ( +
+
+ +

+ Create from template +

+
+
+
+ + Top templates + + +
+ +
    + {templates.map((t, i) => { + const thumbColors = [ + 'bg-gradient-to-br from-amber-400 to-orange-500', + 'bg-gradient-to-br from-sky-400 to-blue-500', + 'bg-gradient-to-br from-violet-400 to-purple-500', + 'bg-gradient-to-br from-emerald-400 to-green-500', + 'bg-gradient-to-br from-rose-400 to-pink-500', + 'bg-gradient-to-br from-indigo-400 to-blue-600', + ]; + const thumb = thumbColors[i % thumbColors.length]; + return ( +
  • + +
  • + ); + })} +
+
+
+

+ See hundreds of templates from the Epitrello community +

+ +
+ ) : ( + + + +
+ Create a new board + + {useTemplate + ? 'Pick a template and set your board details' + : 'Set your board details'} + +
+
-
- -

- Choose a layout with predefined lists -

-
- {templates.map((t) => ( - - ))} +
+ + setName(e.target.value)} + placeholder='Board name' + />
-
-
- - -
+ {useTemplate && ( +
+ +

+ Choose a layout with predefined lists +

+ +
+ )} -
- - -
+
+ + +
-
- -
- {BOARD_COLORS.map((color) => ( - - ))} +
+ + +
+ +
+ +
+ {BOARD_COLORS.map((color) => ( + + ))} +
-
- - - - - + + + + + + )} ); diff --git a/frontend/components/CreateBoardPopoverContent.tsx b/frontend/components/CreateBoardPopoverContent.tsx new file mode 100644 index 0000000..87d61c8 --- /dev/null +++ b/frontend/components/CreateBoardPopoverContent.tsx @@ -0,0 +1,457 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import { toast } from '@/lib/toast'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { useWorkspacesQuery } from '@/lib/queries/workspaces'; +import { getBoardTemplates, type BoardTemplate } from '@/lib/actions/boards'; +import { getTemplates } from '@/lib/actions/templates'; +import Link from 'next/link'; +import { + LayoutGrid, + LayoutList, + ArrowLeft, + ChevronDown, + X, +} from 'lucide-react'; +import { ScrollArea } from '@/components/ui/scroll-area'; + +const FALLBACK_PREDEFINED_TEMPLATES: BoardTemplate[] = [ + { + id: 'blank', + name: 'Blank', + description: 'Empty board with To Do, Doing, Done', + listTitles: ['To Do', 'Doing', 'Done'], + }, + { + id: 'kanban', + name: 'Kanban', + description: 'Classic Kanban: To Do, In Progress, Done', + listTitles: ['To Do', 'In Progress', 'Done'], + }, + { + id: 'sprint', + name: 'Sprint', + description: 'Agile sprint: Backlog, To Do, In Progress, In Review, Done', + listTitles: ['Backlog', 'To Do', 'In Progress', 'In Review', 'Done'], + }, + { + id: 'project', + name: 'Project', + description: 'Project tracking: To Do, In Progress, Blocked, Done', + listTitles: ['To Do', 'In Progress', 'Blocked', 'Done'], + }, +]; + +const BOARD_COLORS = [ + { + value: 'bg-gradient-to-r from-purple-700 to-purple-500', + label: 'Purple', + preview: 'bg-gradient-to-r from-purple-700 to-purple-500', + }, + { + value: 'bg-gradient-to-r from-pink-500 to-purple-400', + label: 'Pink Purple', + preview: 'bg-gradient-to-r from-pink-500 to-purple-400', + }, + { + value: 'bg-gradient-to-r from-orange-500 to-red-500', + label: 'Orange Red', + preview: 'bg-gradient-to-r from-orange-500 to-red-500', + }, + { + value: 'bg-gradient-to-r from-blue-600 to-blue-400', + label: 'Blue', + preview: 'bg-gradient-to-r from-blue-600 to-blue-400', + }, + { + value: 'bg-gradient-to-r from-green-600 to-green-400', + label: 'Green', + preview: 'bg-gradient-to-r from-green-600 to-green-400', + }, + { + value: 'bg-gradient-to-r from-indigo-600 to-indigo-400', + label: 'Indigo', + preview: 'bg-gradient-to-r from-indigo-600 to-indigo-400', + }, +]; + +const THUMB_COLORS = [ + 'bg-gradient-to-br from-amber-400 to-orange-500', + 'bg-gradient-to-br from-sky-400 to-blue-500', + 'bg-gradient-to-br from-violet-400 to-purple-500', + 'bg-gradient-to-br from-emerald-400 to-green-500', + 'bg-gradient-to-br from-rose-400 to-pink-500', + 'bg-gradient-to-br from-indigo-400 to-blue-600', +]; + +export type CreateBoardPayload = { + name: string; + workspaceId?: string; + visibility?: string; + background?: string; + templateId?: string; +}; + +export default function CreateBoardPopoverContent({ + open, + onClose, + onCreate, +}: { + open: boolean; + onClose: () => void; + onCreate: (payload: CreateBoardPayload) => void; +}) { + type Step = 'choice' | 'templatePicker' | 'form'; + const [step, setStep] = useState('choice'); + const [useTemplate, setUseTemplate] = useState(false); + const [name, setName] = useState(''); + const [selectedColor, setSelectedColor] = useState(BOARD_COLORS[1].value); + const [templates, setTemplates] = useState([]); + const [selectedTemplateId, setSelectedTemplateId] = useState('blank'); + const [workspaceId, setWorkspaceId] = useState(undefined); + const [visibility, setVisibility] = useState('personal'); + + const { data: workspacesData } = useWorkspacesQuery(!!open); + const workspaces = (workspacesData ?? []).map((w) => ({ + id: w.id, + name: w.name, + })); + const effectiveWorkspaceId = workspaceId ?? workspaces[0]?.id; + + useEffect(() => { + if (!open) return; + let cancelled = false; + const predefinedPromise = getBoardTemplates().catch( + () => FALLBACK_PREDEFINED_TEMPLATES, + ); + const customPromise = effectiveWorkspaceId + ? getTemplates(effectiveWorkspaceId) + .then((list) => + list.map((t) => ({ + id: t.id, + name: t.name, + description: t.description ?? '', + listTitles: (t.lists ?? []).map((l) => l.title), + })), + ) + .catch(() => []) + : Promise.resolve([]); + Promise.all([predefinedPromise, customPromise]).then( + ([predefined, custom]) => { + if (!cancelled) setTemplates([...predefined, ...custom]); + }, + ); + return () => { + cancelled = true; + }; + }, [open, effectiveWorkspaceId]); + + const submit = (e?: React.FormEvent) => { + e?.preventDefault(); + if (!name.trim()) { + toast.error('Please provide a name'); + return; + } + if (workspaces.length === 0) { + toast.error('Create a workspace first'); + return; + } + onCreate({ + name: name.trim(), + workspaceId: effectiveWorkspaceId, + visibility, + background: selectedColor, + templateId: useTemplate ? selectedTemplateId || undefined : undefined, + }); + setStep('choice'); + setUseTemplate(false); + setName(''); + setSelectedTemplateId('blank'); + onClose(); + }; + + if (step === 'choice') { + return ( +
+ +
+ +
+ ); + } + + if (step === 'templatePicker') { + return ( +
+
+ +

+ Create from template +

+ +
+
+ + Top templates + + +
+
+ +
    + {templates.map((t, i) => ( +
  • + +
  • + ))} +
+
+
+
+

+ See hundreds of templates from the Epitrello community +

+ +
+
+ ); + } + + return ( +
+
+ +
+

+ Create a new board +

+

+ {useTemplate + ? 'Pick a template and set your board details' + : 'Set your board details'} +

+
+
+
+
+
+ + setName(e.target.value)} + placeholder='Board name' + /> +
+ {useTemplate && ( +
+ + +
+ )} +
+ + +
+
+ + +
+
+ +
+ {BOARD_COLORS.map((color) => ( + + ))} +
+
+
+ + +
+
+
+
+ ); +} diff --git a/frontend/components/ListColumn/index.tsx b/frontend/components/ListColumn/index.tsx index a492dc2..4360dc6 100644 --- a/frontend/components/ListColumn/index.tsx +++ b/frontend/components/ListColumn/index.tsx @@ -418,7 +418,6 @@ export default function ListColumn({
- {/* Cards area (droppable for @dnd-kit) */}
0 ? 'max-h-full' : '' diff --git a/frontend/components/Topbar.tsx b/frontend/components/Topbar.tsx index 50f4cf8..f28b62c 100644 --- a/frontend/components/Topbar.tsx +++ b/frontend/components/Topbar.tsx @@ -3,17 +3,13 @@ import React, { useEffect, useState } from 'react'; import Link from 'next/link'; import { usePathname, useRouter } from 'next/navigation'; -import CreateBoardModal from './CreateBoardModal'; +import CreateBoardPopoverContent from './CreateBoardPopoverContent'; import { toast } from '@/lib/toast'; import { Search, Bell } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { ThemeToggle } from './ThemeToggle'; import { SearchWithAdvancedInput } from './SearchWithAdvancedInput'; -import { - Dialog, - DialogContent, - DialogTitle, -} from '@/components/ui/dialog'; +import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'; import { clearAuthToken, clearEpitrelloLocalStorage, @@ -38,8 +34,15 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { getAvatarColor } from '@/lib/utils/avatar-colors'; +import { createBoard as createBoardAction, type Visibility } from '@/lib/actions/boards'; +import { workspaceBoardsQueryKey } from '@/lib/queries/workspaces'; export default function Topbar() { const pathname = usePathname(); @@ -225,62 +228,49 @@ export default function Topbar() { .slice(0, 2); }; - const [createOpen, setCreateOpen] = useState(false); + const [createPopoverOpen, setCreatePopoverOpen] = useState(false); + const [createPopoverKey, setCreatePopoverKey] = useState(0); - const createBoard = (payload?: { + const createBoard = async (payload?: { name?: string; workspaceId?: string; visibility?: string; background?: string; + templateId?: string; }) => { - if (!payload) { - setCreateOpen(true); - return; - } + if (!payload) return; - const { name, workspaceId, visibility, background } = payload; - if (!name) return; + const { name, workspaceId, visibility, background, templateId } = payload; + if (!name?.trim()) return; - try { - const backgrounds = [ - 'bg-gradient-to-br from-amber-400 to-orange-500', - 'bg-gradient-to-br from-sky-400 to-blue-500', - 'bg-gradient-to-br from-emerald-400 to-green-500', - 'bg-gradient-to-br from-violet-400 to-purple-500', - 'bg-gradient-to-br from-rose-400 to-pink-500', - 'bg-gradient-to-br from-cyan-400 to-teal-500', - ]; - - const selectedBackground = - background || - backgrounds[Math.floor(Math.random() * backgrounds.length)]; + const visMap: Record = { + personal: 'PRIVATE', + workspace: 'WORKSPACE', + public: 'PUBLIC', + }; - const raw = localStorage.getItem('epitrello_boards'); - const boards = raw ? JSON.parse(raw) : []; - const id = - typeof crypto !== 'undefined' && - 'randomUUID' in crypto && - typeof crypto.randomUUID === 'function' - ? crypto.randomUUID() - : Date.now().toString(); - const board = { - id, - name, - description: undefined, - background: selectedBackground, - members: 1, + try { + const newBoard = await createBoardAction({ + title: name.trim(), + visibility: visibility ? visMap[visibility] : undefined, workspaceId, - visibility, - }; + background, + templateId, + }); - const next = [board, ...boards]; - localStorage.setItem('epitrello_boards', JSON.stringify(next)); - window.dispatchEvent(new Event('epitrello:boards-updated')); + if (newBoard.workspaceId) { + queryClient.invalidateQueries({ + queryKey: workspaceBoardsQueryKey(newBoard.workspaceId), + }); + } + queryClient.invalidateQueries({ queryKey: ['workspaces'] }); - router.push(`/boards/${id}`); + router.push(`/boards/${newBoard.id}`); } catch (e) { console.error(e); - toast.error('Unable to create board'); + const msg = e instanceof Error ? e.message : 'Unable to create board'; + toast.error(msg); + throw e; } }; @@ -319,28 +309,44 @@ export default function Topbar() {
- {/* Create board */} - - - setCreateOpen(false)} - onCreate={(p) => createBoard(p)} - /> + + + + + setCreatePopoverOpen(false)} + onCreate={async (p) => { + try { + await createBoard(p); + setCreatePopoverOpen(false); + } catch { + /* createBoard already shows toast */ + } + }} + /> + + setShowSearchDialog(false)} /> - ); } diff --git a/frontend/components/ui/chart.tsx b/frontend/components/ui/chart.tsx new file mode 100644 index 0000000..9e1f3fe --- /dev/null +++ b/frontend/components/ui/chart.tsx @@ -0,0 +1,357 @@ +'use client'; + +import * as React from 'react'; +import * as RechartsPrimitive from 'recharts'; + +import { cn } from '@/lib/utils'; + +// Format: { THEME_NAME: CSS_SELECTOR } +const THEMES = { light: '', dark: '.dark' } as const; + +export type ChartConfig = { + [k in string]: { + label?: React.ReactNode; + icon?: React.ComponentType; + } & ( + | { color?: string; theme?: never } + | { color?: never; theme: Record } + ); +}; + +type ChartContextProps = { + config: ChartConfig; +}; + +const ChartContext = React.createContext(null); + +function useChart() { + const context = React.useContext(ChartContext); + + if (!context) { + throw new Error('useChart must be used within a '); + } + + return context; +} + +function ChartContainer({ + id, + className, + children, + config, + ...props +}: React.ComponentProps<'div'> & { + config: ChartConfig; + children: React.ComponentProps< + typeof RechartsPrimitive.ResponsiveContainer + >['children']; +}) { + const uniqueId = React.useId(); + const chartId = `chart-${id || uniqueId.replace(/:/g, '')}`; + + return ( + +
+ + + {children} + +
+
+ ); +} + +const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { + const colorConfig = Object.entries(config).filter( + ([, config]) => config.theme || config.color, + ); + + if (!colorConfig.length) { + return null; + } + + return ( +