From 4777e3dc035895cf5b0f4fe709bc914e199210da Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Tue, 4 Nov 2025 10:53:31 +0100 Subject: [PATCH] Remove secrets from env files --- .gitignore | 4 + SECURITY_NOTICE.md | 175 ++++ api/.env.development | 12 - api/.env.production | 8 +- api/.env.staging | 11 - api/.gitignore | 1 + api/package-lock.json | 227 ++++- api/package.json | 1 + api/src/config.ts | 54 +- api/src/database.types.ts | 1260 +++++++++++++-------------- api/src/helpers.ts | 2 +- api/src/index.ts | 116 +-- api/src/middleware.ts | 168 ++-- api/src/notes.ts | 110 +-- api/src/public.ts | 218 ++--- api/src/routers.ts | 71 +- api/src/secrets.ts | 51 ++ api/src/stripe.ts | 490 ++++++----- api/src/stripeSync.ts | 34 +- api/src/tablo.ts | 1198 ++++++++++++------------- api/src/tablo_data.ts | 293 ++++--- api/src/tasks.ts | 176 ++-- api/src/transporter.ts | 6 +- api/src/user.ts | 380 ++++---- docs/GOOGLE_SECRET_MANAGER_SETUP.md | 299 +++++++ 25 files changed, 3023 insertions(+), 2342 deletions(-) create mode 100644 SECURITY_NOTICE.md create mode 100644 api/src/secrets.ts create mode 100644 docs/GOOGLE_SECRET_MANAGER_SETUP.md diff --git a/.gitignore b/.gitignore index 2582c5c..94a86b5 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,10 @@ __pycache__/ .coverage htmlcov/ +# Environment files +.env* +!.env.example + .turbo dist .wrangler \ No newline at end of file diff --git a/SECURITY_NOTICE.md b/SECURITY_NOTICE.md new file mode 100644 index 0000000..2e72fd5 --- /dev/null +++ b/SECURITY_NOTICE.md @@ -0,0 +1,175 @@ +# ⚠️ SECURITY NOTICE - .env Files Removed from Git + +## What Happened + +Multiple `.env` files containing potentially sensitive credentials were being tracked in git. These files have now been **removed from version control** but remain on your local filesystem. + +## Files Removed from Git + +The following files were removed from git tracking: + +- `api/.env.development` +- `api/.env.production` +- `api/.env.staging` +- `apps/external/.env.production` +- `apps/main/.env.production` +- `apps/main/.env.staging` +- `backend/app/.env` +- `xtablo-expo/.env` + +**Note:** The files still exist locally - they're just no longer tracked by git. + +## Updated .gitignore + +Both `.gitignore` files have been updated to prevent this in the future: + +```gitignore +# Environment files +.env* +!.env.example +``` + +This will: +- ✅ Ignore all `.env*` files (`.env`, `.env.development`, `.env.production`, etc.) +- ✅ Allow `.env.example` files to be committed (they should contain no real secrets) + +## ⚠️ IMPORTANT: Security Actions Required + +### 1. Review Git History + +The `.env` files may have been committed in the past with sensitive credentials. Check the git history: + +```bash +# See when .env files were last committed +git log --all --full-history -- "**/.env*" + +# View the contents of a specific commit (replace COMMIT_HASH) +git show COMMIT_HASH:api/.env.production +``` + +### 2. Rotate Compromised Credentials + +If any of these files were committed with real credentials, you should **rotate those credentials immediately**: + +#### For API secrets in `api/.env.*`: +- [ ] **Supabase**: Regenerate service role key (Supabase Dashboard → Settings → API) +- [ ] **Stripe**: Regenerate secret keys (Stripe Dashboard → Developers → API keys) +- [ ] **Stream Chat**: Regenerate API secret (Stream Dashboard) +- [ ] **Email OAuth**: Revoke and regenerate OAuth tokens (Google Cloud Console) +- [ ] **Cloudflare R2**: Regenerate access keys (Cloudflare Dashboard → R2 → Manage R2 API Tokens) + +#### For frontend env files: +- [ ] Check if any sensitive keys were in `apps/main/.env.*` or `apps/external/.env.*` +- [ ] Regenerate any exposed publishable keys if necessary + +### 3. Use Google Secret Manager for Production + +Since you've just set up Google Secret Manager, move your production secrets there: + +```bash +# Migrate production secrets to Google Secret Manager +cd api +./scripts/migrate-env-to-secrets.sh .env.production your-gcp-project-id + +# Verify they were created +./scripts/verify-secrets.sh your-gcp-project-id +``` + +After migrating: +- Delete the local `.env.production` file (or remove all sensitive values) +- Use `.env.example` as a template for what should be configured + +### 4. Clean Git History (Optional but Recommended) + +If sensitive credentials were committed, consider cleaning the git history. **Warning: This is destructive and requires team coordination.** + +```bash +# Option A: Using BFG Repo-Cleaner (recommended) +# Download from: https://rtyley.github.io/bfg-repo-cleaner/ +java -jar bfg.jar --delete-files .env.* --no-blob-protection +git reflog expire --expire=now --all +git gc --prune=now --aggressive + +# Option B: Using git-filter-repo +# Install: pip install git-filter-repo +git filter-repo --path-glob '**/.env.*' --invert-paths + +# After either option, force push (coordinate with team first!) +git push origin --force --all +``` + +**Important:** Cleaning git history will: +- Rewrite all commit hashes +- Require all team members to re-clone the repository +- Break any external references to commits (PRs, issues, etc.) + +Only do this if: +1. You've confirmed sensitive credentials were committed +2. You've rotated all those credentials +3. You've coordinated with your team + +### 5. Prevent Future Issues + +**Best Practices:** + +1. **Always use `.env.example`** files (committed) with placeholder values: + ```bash + # .env.example + STRIPE_SECRET_KEY=sk_test_REPLACE_ME + SUPABASE_SERVICE_ROLE_KEY=REPLACE_WITH_YOUR_KEY + ``` + +2. **Never commit actual `.env` files** - they're now in `.gitignore` + +3. **Use Google Secret Manager** for production/staging environments + +4. **Review files before committing:** + ```bash + git status + git diff --cached + ``` + +5. **Use pre-commit hooks** to prevent accidental commits: + ```bash + # Install pre-commit: https://pre-commit.com/ + # Add a hook to check for secrets + ``` + +## Current Status + +✅ `.env` files removed from git tracking +✅ `.gitignore` updated to prevent future commits +✅ Local `.env` files preserved (still work for development) +⚠️ Files staged for removal (need to commit) + +## Next Steps + +1. **Review this security notice carefully** +2. **Check git history** for exposed credentials +3. **Rotate any exposed credentials** +4. **Commit the changes:** + ```bash + git add .gitignore + git commit -m "security: Remove .env files from git and update .gitignore" + ``` +5. **Push the changes** (after rotating credentials if needed) +6. **Migrate production secrets** to Google Secret Manager + +## Questions or Concerns? + +If you have questions about: +- What credentials might be exposed +- How to rotate specific credentials +- Cleaning git history +- Setting up Google Secret Manager + +Please refer to: +- `docs/GOOGLE_SECRET_MANAGER_SETUP.md` - For Secret Manager setup +- `api/GOOGLE_SECRET_MANAGER.md` - Quick reference +- Your cloud provider's documentation for credential rotation + +--- + +**Generated:** 2025-11-03 +**Action Required:** See checklist above + diff --git a/api/.env.development b/api/.env.development index 67b6687..3ff9fa1 100644 --- a/api/.env.development +++ b/api/.env.development @@ -1,25 +1,13 @@ SUPABASE_URL=https://mhcafqvzbrrwvahpvvzd.supabase.co -SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im1oY2FmcXZ6YnJyd3ZhaHB2dnpkIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc0MTI0MTMyMSwiZXhwIjoyMDU2ODE3MzIxfQ.9r33CUsu6ZR4vyv4ed-UY6cLE1FZzSSxTNE8pFUKjN4 -SUPABASE_CONNECTION_STRING=postgresql://postgres:mke0dwp@cnv.MFZ@mpa@db.mhcafqvzbrrwvahpvvzd.supabase.co:5432/postgres -SUPABASE_CA_CERT="LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUR4RENDQXF5Z0F3SUJBZ0lVYkx4TW9kNjJQMmt0Q2lBa3huS0p3dEU5VlBZd0RRWUpLb1pJaHZjTkFRRUwKQlFBd2F6RUxNQWtHQTFVRUJoTUNWVk14RURBT0JnTlZCQWdNQjBSbGJIZGhjbVV4RXpBUkJnTlZCQWNNQ2s1bApkeUJEWVhOMGJHVXhGVEFUQmdOVkJBb01ERk4xY0dGaVlYTmxJRWx1WXpFZU1Cd0dBMVVFQXd3VlUzVndZV0poCmMyVWdVbTl2ZENBeU1ESXhJRU5CTUI0WERUSXhNRFF5T0RFd05UWTFNMW9YRFRNeE1EUXlOakV3TlRZMU0xb3cKYXpFTE1Ba0dBMVVFQmhNQ1ZWTXhFREFPQmdOVkJBZ01CMFJsYkhkaGNtVXhFekFSQmdOVkJBY01DazVsZHlCRApZWE4wYkdVeEZUQVRCZ05WQkFvTURGTjFjR0ZpWVhObElFbHVZekVlTUJ3R0ExVUVBd3dWVTNWd1lXSmhjMlVnClVtOXZkQ0F5TURJeElFTkJNSUlCSWpBTkJna3Foa2lHOXcwQkFRRUZBQU9DQVE4QU1JSUJDZ0tDQVFFQXFRWFcKUXlIT0IrcVIyR0pvYkNxL0NCbVE0MEcwb0RtQ0MzbXpWbm44c3Y0WE5lV3RFNVhjRUwwdVZpaDdKbzREa3gxUQpEbUdIQkgxekRmZ3MycVhpTGI2eHB3L0NLUVB5cFpXMUpzc09UTUlmUXBwTlE4N0s3NVlhMHAyNVkzZVBTMnQyCkd0dkh4TmpVVjZrak9aakVuMnlXRWNCZHBPVkNVWUJWRkJOTUI0WUJIa05SRGEvK1M0dXl3QW9hVFduQ0pMVWkKY3ZUbEhtTXc2eFNRUW4xVWZSUUhrNTBETUNFSjdDeTFSeHJaSnJrWFhSUDNMcVFMMmlqSjZGNHlNZmgrR3liNApPNFhham9Wai8rUjRHd3l3S1lyclM4UHJTTnR3eHI1U3RsUU84eklRVVNNaXEyNndNOG1nRUxGbFMvMzJVY2x0Ck5hUTF4QlJpemt6cFpjdDlEd0lEQVFBQm8yQXdYakFMQmdOVkhROEVCQU1DQVFZd0hRWURWUjBPQkJZRUZLalgKdVhZMzJDenRraEltbmc0eUpOVXRhVVlzTUI4R0ExVWRJd1FZTUJhQUZLalh1WFkzMkN6dGtoSW1uZzR5Sk5VdAphVVlzTUE4R0ExVWRFd0VCL3dRRk1BTUJBZjh3RFFZSktvWklodmNOQVFFTEJRQURnZ0VCQUI4c3B6Tm4rNFZVCnRWeGJkTWFYKzM5WjUwc2M3dUFUbXVzMTZqbW1IamhJSHorbC85R2xKNUtxQU1PeDI2bVBaZ2Z6RzdvbmVMMmIKVlcrV2dZVWtUVDNYRVBGV25UcDJSSndRYW84L3RZUFhXRUpEYzBXVlFIcnBtbldPRktVL2QzTXFCZ0JtNXkrNgpqQjgxVFUvUkcyclZlclBEV1ArMU1NY05OeTA0OTFDVEw1WFFaN0pmREpKOUNDbVhTZHRUbDR1VVFuU3V2L1F4CkNlYTEzQlgyWmdKYzdBdTMwdmloTGh1YjUyRGU0UC80Z29uS3NOSFlkYldqZzdPV0t3TnYveml0R0RWREI5WTIKQ01UeVpLRzNYRXU1R2hsMUxFbkkzUW1FS3NxYUNMdjEyQm5WamJrU2Vac01uZXZKUHMxWWU2VGpqSndkaWs1UApvL2JLaUl6K0ZxOD0KLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo=" STREAM_CHAT_API_KEY=t5vvvddteapa -STREAM_CHAT_API_SECRET=zrr32sqenw3atpv9rnz2nhhyyncf7bunr7fmfqy9r7e69fcw978dhzevmhpxa2jj - -STRIPE_SECRET_KEY=sk_test_51Qc159AmcXPHW4mTeEs86NXY2lAz6pPKiSteECBTsQ2BmaJxeFkbO4uopoMZM8USggRYJjuwJ4GCXVzy6ROT1hMJ00NJGOUM33 -STRIPE_WEBHOOK_SECRET=whsec_4c6f3742c4f3760eff1ef974202cb7f27acc93b8a0da6529db7b2ff2d5acec02 - XTABLO_URL="https://app-staging.xtablo.com" CORS_ORIGIN="http://localhost:5173,http://localhost:5174" R2_ACCOUNT_ID="9715fa14c5e5d1612301572cf1c6bbee" -R2_ACCESS_KEY_ID="caeb987bbcd601708a93c6aa562064ef" -R2_SECRET_ACCESS_KEY="42e455b25804687f7cff3d15be23c1f0f47ca742d7a41b6fa1a05a91041e0215" TASKS_SECRET="hello" EMAIL_USER="baptiste@xtablo.com" EMAIL_CLIENT_ID="904332563417-e2n7pchtgnkrkp360baaebfeig55maig.apps.googleusercontent.com" -EMAIL_CLIENT_SECRET="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" -EMAIL_REFRESH_TOKEN="1//04dRsWFVjr0mqCgYIARAAGAQSNwF-L9IrN3JicCv2ib4F6AQlactB4CE6Q4ST_tEVVdmmECly_-05INeTeqidxmpRHHDJDM8UFBk" diff --git a/api/.env.production b/api/.env.production index a49d6eb..7f06b71 100644 --- a/api/.env.production +++ b/api/.env.production @@ -1,19 +1,13 @@ SUPABASE_URL=https://mhcafqvzbrrwvahpvvzd.supabase.co -SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im1oY2FmcXZ6YnJyd3ZhaHB2dnpkIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc0MTI0MTMyMSwiZXhwIjoyMDU2ODE3MzIxfQ.9r33CUsu6ZR4vyv4ed-UY6cLE1FZzSSxTNE8pFUKjN4 STREAM_CHAT_API_KEY=v4yf8rs94aa8 -STREAM_CHAT_API_SECRET=jq2szvv73ua7sz9tvr9y24dxg37sw8ue8t576fu7ggr4h6wvcmunby4gvte8tm8f XTABLO_URL=https://app.xtablo.com CORS_ORIGIN="https://app.xtablo.com,https://embed.xtablo.com" R2_ACCOUNT_ID="9715fa14c5e5d1612301572cf1c6bbee" -R2_ACCESS_KEY_ID="caeb987bbcd601708a93c6aa562064ef" -R2_SECRET_ACCESS_KEY="42e455b25804687f7cff3d15be23c1f0f47ca742d7a41b6fa1a05a91041e0215" TASKS_SECRET="gT3BAytmNwhe1wKmvgREBlWcqK0=" EMAIL_USER="baptiste@xtablo.com" -EMAIL_CLIENT_ID="904332563417-e2n7pchtgnkrkp360baaebfeig55maig.apps.googleusercontent.com" -EMAIL_CLIENT_SECRET="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" -EMAIL_REFRESH_TOKEN="1//04dRsWFVjr0mqCgYIARAAGAQSNwF-L9IrN3JicCv2ib4F6AQlactB4CE6Q4ST_tEVVdmmECly_-05INeTeqidxmpRHHDJDM8UFBk" \ No newline at end of file +EMAIL_CLIENT_ID="904332563417-e2n7pchtgnkrkp360baaebfeig55maig.apps.googleusercontent.com" \ No newline at end of file diff --git a/api/.env.staging b/api/.env.staging index b1dc6e2..663a395 100644 --- a/api/.env.staging +++ b/api/.env.staging @@ -1,22 +1,11 @@ SUPABASE_URL=https://mhcafqvzbrrwvahpvvzd.supabase.co -SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im1oY2FmcXZ6YnJyd3ZhaHB2dnpkIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc0MTI0MTMyMSwiZXhwIjoyMDU2ODE3MzIxfQ.9r33CUsu6ZR4vyv4ed-UY6cLE1FZzSSxTNE8pFUKjN4 -SUPABASE_CONNECTION_STRING=postgresql://postgres:mke0dwp@cnv.MFZ@mpa@db.mhcafqvzbrrwvahpvvzd.supabase.co:5432/postgres -SUPABASE_CA_CERT="LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUR4RENDQXF5Z0F3SUJBZ0lVYkx4TW9kNjJQMmt0Q2lBa3huS0p3dEU5VlBZd0RRWUpLb1pJaHZjTkFRRUwKQlFBd2F6RUxNQWtHQTFVRUJoTUNWVk14RURBT0JnTlZCQWdNQjBSbGJIZGhjbVV4RXpBUkJnTlZCQWNNQ2s1bApkeUJEWVhOMGJHVXhGVEFUQmdOVkJBb01ERk4xY0dGaVlYTmxJRWx1WXpFZU1Cd0dBMVVFQXd3VlUzVndZV0poCmMyVWdVbTl2ZENBeU1ESXhJRU5CTUI0WERUSXhNRFF5T0RFd05UWTFNMW9YRFRNeE1EUXlOakV3TlRZMU0xb3cKYXpFTE1Ba0dBMVVFQmhNQ1ZWTXhFREFPQmdOVkJBZ01CMFJsYkhkaGNtVXhFekFSQmdOVkJBY01DazVsZHlCRApZWE4wYkdVeEZUQVRCZ05WQkFvTURGTjFjR0ZpWVhObElFbHVZekVlTUJ3R0ExVUVBd3dWVTNWd1lXSmhjMlVnClVtOXZkQ0F5TURJeElFTkJNSUlCSWpBTkJna3Foa2lHOXcwQkFRRUZBQU9DQVE4QU1JSUJDZ0tDQVFFQXFRWFcKUXlIT0IrcVIyR0pvYkNxL0NCbVE0MEcwb0RtQ0MzbXpWbm44c3Y0WE5lV3RFNVhjRUwwdVZpaDdKbzREa3gxUQpEbUdIQkgxekRmZ3MycVhpTGI2eHB3L0NLUVB5cFpXMUpzc09UTUlmUXBwTlE4N0s3NVlhMHAyNVkzZVBTMnQyCkd0dkh4TmpVVjZrak9aakVuMnlXRWNCZHBPVkNVWUJWRkJOTUI0WUJIa05SRGEvK1M0dXl3QW9hVFduQ0pMVWkKY3ZUbEhtTXc2eFNRUW4xVWZSUUhrNTBETUNFSjdDeTFSeHJaSnJrWFhSUDNMcVFMMmlqSjZGNHlNZmgrR3liNApPNFhham9Wai8rUjRHd3l3S1lyclM4UHJTTnR3eHI1U3RsUU84eklRVVNNaXEyNndNOG1nRUxGbFMvMzJVY2x0Ck5hUTF4QlJpemt6cFpjdDlEd0lEQVFBQm8yQXdYakFMQmdOVkhROEVCQU1DQVFZd0hRWURWUjBPQkJZRUZLalgKdVhZMzJDenRraEltbmc0eUpOVXRhVVlzTUI4R0ExVWRJd1FZTUJhQUZLalh1WFkzMkN6dGtoSW1uZzR5Sk5VdAphVVlzTUE4R0ExVWRFd0VCL3dRRk1BTUJBZjh3RFFZSktvWklodmNOQVFFTEJRQURnZ0VCQUI4c3B6Tm4rNFZVCnRWeGJkTWFYKzM5WjUwc2M3dUFUbXVzMTZqbW1IamhJSHorbC85R2xKNUtxQU1PeDI2bVBaZ2Z6RzdvbmVMMmIKVlcrV2dZVWtUVDNYRVBGV25UcDJSSndRYW84L3RZUFhXRUpEYzBXVlFIcnBtbldPRktVL2QzTXFCZ0JtNXkrNgpqQjgxVFUvUkcyclZlclBEV1ArMU1NY05OeTA0OTFDVEw1WFFaN0pmREpKOUNDbVhTZHRUbDR1VVFuU3V2L1F4CkNlYTEzQlgyWmdKYzdBdTMwdmloTGh1YjUyRGU0UC80Z29uS3NOSFlkYldqZzdPV0t3TnYveml0R0RWREI5WTIKQ01UeVpLRzNYRXU1R2hsMUxFbkkzUW1FS3NxYUNMdjEyQm5WamJrU2Vac01uZXZKUHMxWWU2VGpqSndkaWs1UApvL2JLaUl6K0ZxOD0KLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo=" STREAM_CHAT_API_KEY=t5vvvddteapa -STREAM_CHAT_API_SECRET=zrr32sqenw3atpv9rnz2nhhyyncf7bunr7fmfqy9r7e69fcw978dhzevmhpxa2jj - -STRIPE_SECRET_KEY=sk_test_51Qc159AmcXPHW4mTeEs86NXY2lAz6pPKiSteECBTsQ2BmaJxeFkbO4uopoMZM8USggRYJjuwJ4GCXVzy6ROT1hMJ00NJGOUM33 -STRIPE_WEBHOOK_SECRET=whsec_4c6f3742c4f3760eff1ef974202cb7f27acc93b8a0da6529db7b2ff2d5acec02 XTABLO_URL="https://app-staging.xtablo.com" CORS_ORIGIN="https://app-staging.xtablo.com" R2_ACCOUNT_ID="9715fa14c5e5d1612301572cf1c6bbee" -R2_ACCESS_KEY_ID="caeb987bbcd601708a93c6aa562064ef" -R2_SECRET_ACCESS_KEY="42e455b25804687f7cff3d15be23c1f0f47ca742d7a41b6fa1a05a91041e0215" EMAIL_USER="baptiste@xtablo.com" EMAIL_CLIENT_ID="904332563417-e2n7pchtgnkrkp360baaebfeig55maig.apps.googleusercontent.com" -EMAIL_CLIENT_SECRET="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" -EMAIL_REFRESH_TOKEN="1//04dRsWFVjr0mqCgYIARAAGAQSNwF-L9IrN3JicCv2ib4F6AQlactB4CE6Q4ST_tEVVdmmECly_-05INeTeqidxmpRHHDJDM8UFBk" diff --git a/api/.gitignore b/api/.gitignore index cecfe13..6041825 100644 --- a/api/.gitignore +++ b/api/.gitignore @@ -17,6 +17,7 @@ node_modules/ # env .env.development +!.env.example .dev.vars # logs diff --git a/api/package-lock.json b/api/package-lock.json index f4f028c..fd0f3b1 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -7,6 +7,7 @@ "name": "xtablo-api", "dependencies": { "@aws-sdk/client-s3": "^3.850.0", + "@google-cloud/secret-manager": "^6.1.1", "@hono/node-server": "^1.14.4", "@supabase/stripe-sync-engine": "^0.45.0", "@supabase/supabase-js": "^2.49.4", @@ -2226,6 +2227,128 @@ "node": ">=18" } }, + "node_modules/@google-cloud/secret-manager": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@google-cloud/secret-manager/-/secret-manager-6.1.1.tgz", + "integrity": "sha512-dwSuxJ9RNmAW46FjK1StiNIeOiSHHQs/XIy4VArJ6bBMR+WsIvR+zhPh2pa40aFa9uTty67j38Rl268TVV62EA==", + "dependencies": { + "google-gax": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@google-cloud/secret-manager/node_modules/@grpc/proto-loader": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.0.tgz", + "integrity": "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.3", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@google-cloud/secret-manager/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/@google-cloud/secret-manager/node_modules/google-gax": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/google-gax/-/google-gax-5.0.5.tgz", + "integrity": "sha512-VuC6nVnPVfo/M1WudLoS4Y3dTepVndZatUmeb0nUNmfzft6mKSy8ffDh4h5qxR7L9lslDxNpWPYsuPrFboOmTw==", + "dependencies": { + "@grpc/grpc-js": "^1.12.6", + "@grpc/proto-loader": "^0.8.0", + "duplexify": "^4.1.3", + "google-auth-library": "^10.1.0", + "google-logging-utils": "^1.1.1", + "node-fetch": "^3.3.2", + "object-hash": "^3.0.0", + "proto3-json-serializer": "^3.0.0", + "protobufjs": "^7.5.3", + "retry-request": "^8.0.0", + "rimraf": "^5.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@google-cloud/secret-manager/node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@google-cloud/secret-manager/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@google-cloud/secret-manager/node_modules/proto3-json-serializer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-3.0.4.tgz", + "integrity": "sha512-E1sbAYg3aEbXrq0n1ojJkRHQJGE1kaE/O6GLA94y8rnJBfgvOPTOd1b9hOceQK1FFZI9qMh1vBERCyO2ifubcw==", + "dependencies": { + "protobufjs": "^7.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@google-cloud/secret-manager/node_modules/retry-request": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-8.0.2.tgz", + "integrity": "sha512-JzFPAfklk1kjR1w76f0QOIhoDkNkSqW8wYKT08n9yysTmZfB+RQ2QoXoTAeOi1HD9ZipTyTAZg3c4pM/jeqgSw==", + "dependencies": { + "extend": "^3.0.2", + "teeny-request": "^10.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@google-cloud/secret-manager/node_modules/teeny-request": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-10.1.0.tgz", + "integrity": "sha512-3ZnLvgWF29jikg1sAQ1g0o+lr5JX6sVgYvfUJazn7ZjJroDBUTWp44/+cFVX0bULjv4vci+rBD+oGVAkWqhUbw==", + "dependencies": { + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.0", + "node-fetch": "^3.3.2", + "stream-events": "^1.0.5" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@graphile/logger": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/@graphile/logger/-/logger-0.2.0.tgz", @@ -2235,7 +2358,6 @@ "version": "1.14.0", "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.0.tgz", "integrity": "sha512-N8Jx6PaYzcTRNzirReJCtADVoq4z7+1KQ4E70jTg/koQiMoUSN1kbNjPOqpPbhMFhfU1/l7ixspPl8dNY+FoUg==", - "dev": true, "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" @@ -2248,7 +2370,6 @@ "version": "0.8.0", "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.0.tgz", "integrity": "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==", - "dev": true, "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", @@ -2317,7 +2438,6 @@ "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", @@ -2334,7 +2454,6 @@ "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, "engines": { "node": ">=12" }, @@ -2346,7 +2465,6 @@ "version": "6.2.3", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, "engines": { "node": ">=12" }, @@ -2357,14 +2475,12 @@ "node_modules/@isaacs/cliui/node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==" }, "node_modules/@isaacs/cliui/node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", @@ -2381,7 +2497,6 @@ "version": "7.1.2", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "dev": true, "dependencies": { "ansi-regex": "^6.0.1" }, @@ -2396,7 +2511,6 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", @@ -2473,7 +2587,6 @@ "version": "4.4.2", "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", - "dev": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/js-sdsl" @@ -2623,7 +2736,6 @@ "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, "optional": true, "engines": { "node": ">=14" @@ -3492,7 +3604,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", - "dev": true, "engines": { "node": ">= 10" } @@ -3888,8 +3999,7 @@ "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, "node_modules/base64-js": { "version": "1.5.1", @@ -3960,7 +4070,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, "dependencies": { "balanced-match": "^1.0.0" } @@ -4318,7 +4427,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -4610,7 +4718,6 @@ "version": "4.1.3", "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==", - "dev": true, "dependencies": { "end-of-stream": "^1.4.1", "inherits": "^2.0.3", @@ -4621,8 +4728,7 @@ "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==" }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", @@ -4648,7 +4754,6 @@ "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "dev": true, "dependencies": { "once": "^1.4.0" } @@ -4993,7 +5098,6 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" @@ -5009,7 +5113,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, "engines": { "node": ">=14" }, @@ -5945,8 +6048,7 @@ "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" }, "node_modules/isomorphic-ws": { "version": "5.0.0", @@ -5969,7 +6071,6 @@ "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, "dependencies": { "@isaacs/cliui": "^8.0.2" }, @@ -6210,8 +6311,7 @@ "node_modules/lodash.camelcase": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", - "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", - "dev": true + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==" }, "node_modules/lodash.includes": { "version": "4.3.0", @@ -6379,7 +6479,6 @@ "version": "7.1.2", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "dev": true, "engines": { "node": ">=16 || 14 >=14.17" } @@ -6636,7 +6735,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", - "dev": true, "engines": { "node": ">= 6" } @@ -6677,7 +6775,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "dependencies": { "wrappy": "1" } @@ -6792,8 +6889,7 @@ "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==" }, "node_modules/package-manager-detector": { "version": "1.5.0", @@ -6848,7 +6944,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "engines": { "node": ">=8" } @@ -6857,7 +6952,6 @@ "version": "1.11.1", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" @@ -7385,6 +7479,53 @@ "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==" }, + "node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/run-async": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", @@ -7474,7 +7615,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "dependencies": { "shebang-regex": "^3.0.0" }, @@ -7486,7 +7626,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "engines": { "node": ">=8" } @@ -7719,7 +7858,6 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz", "integrity": "sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==", - "dev": true, "dependencies": { "stubs": "^3.0.0" } @@ -7727,8 +7865,7 @@ "node_modules/stream-shift": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", - "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", - "dev": true + "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==" }, "node_modules/streamsearch": { "version": "1.1.0", @@ -7764,7 +7901,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -7790,7 +7926,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -7843,8 +7978,7 @@ "node_modules/stubs": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/stubs/-/stubs-3.0.0.tgz", - "integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==", - "dev": true + "integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==" }, "node_modules/supports-color": { "version": "7.2.0", @@ -8231,7 +8365,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "dependencies": { "isexe": "^2.0.0" }, @@ -8269,7 +8402,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", @@ -8285,8 +8417,7 @@ "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, "node_modules/ws": { "version": "8.18.2", diff --git a/api/package.json b/api/package.json index 1bf8aee..abfcdd0 100644 --- a/api/package.json +++ b/api/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "@aws-sdk/client-s3": "^3.850.0", + "@google-cloud/secret-manager": "^6.1.1", "@hono/node-server": "^1.14.4", "@supabase/stripe-sync-engine": "^0.45.0", "@supabase/supabase-js": "^2.49.4", diff --git a/api/src/config.ts b/api/src/config.ts index e50a3e6..2495d07 100644 --- a/api/src/config.ts +++ b/api/src/config.ts @@ -1,4 +1,5 @@ import dotenv from "dotenv"; +import type { Secrets } from "./secrets.js"; export interface AppConfig { NODE_ENV: "development" | "production" | "test" | "staging"; @@ -31,12 +32,11 @@ function validateEnvVar(name: string, value: string | undefined): string { return value; } -function createConfig(): AppConfig { +export function createConfig(secrets: Secrets): AppConfig { const NODE_ENV = (process.env.NODE_ENV || "development") as | "development" | "production" - | "staging" - | "test"; + | "staging"; dotenv.config({ path: `.env.${NODE_ENV}` }); @@ -45,31 +45,22 @@ function createConfig(): AppConfig { NODE_ENV, PORT: parseInt(process.env.PORT || "8080", 10), SUPABASE_URL: validateEnvVar("SUPABASE_URL", process.env.SUPABASE_URL), - SUPABASE_SERVICE_ROLE_KEY: validateEnvVar( - "SUPABASE_SERVICE_ROLE_KEY", - process.env.SUPABASE_SERVICE_ROLE_KEY - ), - SUPABASE_CONNECTION_STRING: process.env.SUPABASE_CONNECTION_STRING || "", - SUPABASE_CA_CERT: process.env.SUPABASE_CA_CERT || "", + SUPABASE_SERVICE_ROLE_KEY: secrets.supabaseServiceRoleKey, + SUPABASE_CONNECTION_STRING: secrets.supabaseConnectionString, + SUPABASE_CA_CERT: secrets.supabaseCaCert, STREAM_CHAT_API_KEY: validateEnvVar("STREAM_CHAT_API_KEY", process.env.STREAM_CHAT_API_KEY), - STREAM_CHAT_API_SECRET: validateEnvVar( - "STREAM_CHAT_API_SECRET", - process.env.STREAM_CHAT_API_SECRET - ), - STRIPE_SECRET_KEY: validateEnvVar("STRIPE_SECRET_KEY", process.env.STRIPE_SECRET_KEY), - STRIPE_WEBHOOK_SECRET: validateEnvVar( - "STRIPE_WEBHOOK_SECRET", - process.env.STRIPE_WEBHOOK_SECRET - ), + STREAM_CHAT_API_SECRET: secrets.streamChatApiSecret, + STRIPE_SECRET_KEY: secrets.stripeSecretKey, + STRIPE_WEBHOOK_SECRET: secrets.stripeWebhookSecret, EMAIL_USER: validateEnvVar("EMAIL_USER", process.env.EMAIL_USER), EMAIL_CLIENT_ID: validateEnvVar("EMAIL_CLIENT_ID", process.env.EMAIL_CLIENT_ID), - EMAIL_CLIENT_SECRET: validateEnvVar("EMAIL_CLIENT_SECRET", process.env.EMAIL_CLIENT_SECRET), - EMAIL_REFRESH_TOKEN: validateEnvVar("EMAIL_REFRESH_TOKEN", process.env.EMAIL_REFRESH_TOKEN), + EMAIL_CLIENT_SECRET: secrets.emailClientSecret, + EMAIL_REFRESH_TOKEN: secrets.emailRefreshToken, CORS_ORIGIN: process.env.CORS_ORIGIN || "https://app.xtablo.com", XTABLO_URL: process.env.XTABLO_URL || "https://app.xtablo.com", R2_ACCOUNT_ID: validateEnvVar("R2_ACCOUNT_ID", process.env.R2_ACCOUNT_ID), - R2_ACCESS_KEY_ID: validateEnvVar("R2_ACCESS_KEY_ID", process.env.R2_ACCESS_KEY_ID), - R2_SECRET_ACCESS_KEY: validateEnvVar("R2_SECRET_ACCESS_KEY", process.env.R2_SECRET_ACCESS_KEY), + R2_ACCESS_KEY_ID: secrets.r2AccessKeyId, + R2_SECRET_ACCESS_KEY: secrets.r2SecretAccessKey, TASKS_SECRET: process.env.TASKS_SECRET || "", LOG_LEVEL: "info", }; @@ -79,26 +70,13 @@ function createConfig(): AppConfig { baseConfig.LOG_LEVEL = "debug"; } else if (NODE_ENV === "production") { baseConfig.LOG_LEVEL = "info"; - } else if (NODE_ENV === "test") { - baseConfig.LOG_LEVEL = "warn"; } - // Log configuration info - // console.info(`Configuration loaded for ${NODE_ENV} environment:`); - // console.info(`- Port: ${baseConfig.PORT}`); - // console.info(`- CORS Origins: ${baseConfig.CORS_ORIGIN.join(", ")}`); - // console.info(`- Log Level: ${baseConfig.LOG_LEVEL}`); - // console.info(`- XTablo URL: ${baseConfig.XTABLO_URL}`); - // console.info(`- Supabase URL: ${baseConfig.SUPABASE_URL}`); - // console.info(`- Stream Chat API Key: ${baseConfig.STREAM_CHAT_API_KEY}`); - // console.info(`- Email User: ${baseConfig.EMAIL_USER}`); - // console.info(`- Email Key: ${baseConfig.EMAIL_KEY}`); + console.log("✓ Configuration loaded successfully"); return baseConfig; } -export const config = createConfig(); - // Helper functions for common environment checks -export const isDevelopment = () => config.NODE_ENV === "development"; -export const isProduction = () => config.NODE_ENV === "production"; +// export const isDevelopment = () => config.NODE_ENV === "development"; +// export const isProduction = () => config.NODE_ENV === "production"; diff --git a/api/src/database.types.ts b/api/src/database.types.ts index 2071436..45703d0 100644 --- a/api/src/database.types.ts +++ b/api/src/database.types.ts @@ -1,856 +1,848 @@ -export type Json = - | string - | number - | boolean - | null - | { [key: string]: Json | undefined } - | Json[] +export type Json = string | number | boolean | null | { [key: string]: Json | undefined } | Json[]; export type Database = { // Allows to automatically instantiate createClient with right options // instead of createClient(URL, KEY) __InternalSupabase: { - PostgrestVersion: "13.0.4" - } + PostgrestVersion: "13.0.4"; + }; public: { Tables: { availabilities: { Row: { - availability_data: Json - created_at: string - exceptions: Json | null - id: number - updated_at: string - user_id: string - } + availability_data: Json; + created_at: string; + exceptions: Json | null; + id: number; + updated_at: string; + user_id: string; + }; Insert: { - availability_data?: Json - created_at?: string - exceptions?: Json | null - id?: number - updated_at?: string - user_id: string - } + availability_data?: Json; + created_at?: string; + exceptions?: Json | null; + id?: number; + updated_at?: string; + user_id: string; + }; Update: { - availability_data?: Json - created_at?: string - exceptions?: Json | null - id?: number - updated_at?: string - user_id?: string - } - Relationships: [] - } + availability_data?: Json; + created_at?: string; + exceptions?: Json | null; + id?: number; + updated_at?: string; + user_id?: string; + }; + Relationships: []; + }; calendar_subscriptions: { Row: { - created_at: string | null - id: string - tablo_id: string - token: string - } + created_at: string | null; + id: string; + tablo_id: string; + token: string; + }; Insert: { - created_at?: string | null - id?: string - tablo_id: string - token: string - } + created_at?: string | null; + id?: string; + tablo_id: string; + token: string; + }; Update: { - created_at?: string | null - id?: string - tablo_id?: string - token?: string - } + created_at?: string | null; + id?: string; + tablo_id?: string; + token?: string; + }; Relationships: [ { - foreignKeyName: "calendar_subscriptions_tablo_id_fkey" - columns: ["tablo_id"] - isOneToOne: true - referencedRelation: "events_and_tablos" - referencedColumns: ["tablo_id"] + foreignKeyName: "calendar_subscriptions_tablo_id_fkey"; + columns: ["tablo_id"]; + isOneToOne: true; + referencedRelation: "events_and_tablos"; + referencedColumns: ["tablo_id"]; }, { - foreignKeyName: "calendar_subscriptions_tablo_id_fkey" - columns: ["tablo_id"] - isOneToOne: true - referencedRelation: "tablos" - referencedColumns: ["id"] + foreignKeyName: "calendar_subscriptions_tablo_id_fkey"; + columns: ["tablo_id"]; + isOneToOne: true; + referencedRelation: "tablos"; + referencedColumns: ["id"]; }, { - foreignKeyName: "calendar_subscriptions_tablo_id_fkey" - columns: ["tablo_id"] - isOneToOne: true - referencedRelation: "user_tablos" - referencedColumns: ["id"] + foreignKeyName: "calendar_subscriptions_tablo_id_fkey"; + columns: ["tablo_id"]; + isOneToOne: true; + referencedRelation: "user_tablos"; + referencedColumns: ["id"]; }, - ] - } + ]; + }; devis: { Row: { - client_email: string - created_at: string - date: string - due_date: string - id: string - items: Json - notes: string | null - number: string - status: Database["public"]["Enums"]["devis_status"] - subtotal: number - tax: number - terms: string | null - total: number - updated_at: string - user_id: string - } + client_email: string; + created_at: string; + date: string; + due_date: string; + id: string; + items: Json; + notes: string | null; + number: string; + status: Database["public"]["Enums"]["devis_status"]; + subtotal: number; + tax: number; + terms: string | null; + total: number; + updated_at: string; + user_id: string; + }; Insert: { - client_email: string - created_at?: string - date: string - due_date: string - id?: string - items?: Json - notes?: string | null - number: string - status?: Database["public"]["Enums"]["devis_status"] - subtotal: number - tax: number - terms?: string | null - total: number - updated_at?: string - user_id: string - } + client_email: string; + created_at?: string; + date: string; + due_date: string; + id?: string; + items?: Json; + notes?: string | null; + number: string; + status?: Database["public"]["Enums"]["devis_status"]; + subtotal: number; + tax: number; + terms?: string | null; + total: number; + updated_at?: string; + user_id: string; + }; Update: { - client_email?: string - created_at?: string - date?: string - due_date?: string - id?: string - items?: Json - notes?: string | null - number?: string - status?: Database["public"]["Enums"]["devis_status"] - subtotal?: number - tax?: number - terms?: string | null - total?: number - updated_at?: string - user_id?: string - } - Relationships: [] - } + client_email?: string; + created_at?: string; + date?: string; + due_date?: string; + id?: string; + items?: Json; + notes?: string | null; + number?: string; + status?: Database["public"]["Enums"]["devis_status"]; + subtotal?: number; + tax?: number; + terms?: string | null; + total?: number; + updated_at?: string; + user_id?: string; + }; + Relationships: []; + }; event_types: { Row: { - config: Json - created_at: string | null - deleted_at: string | null - id: string - is_active: boolean - standard_name: string | null - updated_at: string | null - user_id: string - } + config: Json; + created_at: string | null; + deleted_at: string | null; + id: string; + is_active: boolean; + standard_name: string | null; + updated_at: string | null; + user_id: string; + }; Insert: { - config?: Json - created_at?: string | null - deleted_at?: string | null - id?: string - is_active?: boolean - standard_name?: string | null - updated_at?: string | null - user_id: string - } + config?: Json; + created_at?: string | null; + deleted_at?: string | null; + id?: string; + is_active?: boolean; + standard_name?: string | null; + updated_at?: string | null; + user_id: string; + }; Update: { - config?: Json - created_at?: string | null - deleted_at?: string | null - id?: string - is_active?: boolean - standard_name?: string | null - updated_at?: string | null - user_id?: string - } - Relationships: [] - } + config?: Json; + created_at?: string | null; + deleted_at?: string | null; + id?: string; + is_active?: boolean; + standard_name?: string | null; + updated_at?: string | null; + user_id?: string; + }; + Relationships: []; + }; events: { Row: { - created_at: string | null - created_by: string - deleted_at: string | null - description: string | null - end_time: string | null - id: string - start_date: string - start_time: string - tablo_id: string - title: string - } + created_at: string | null; + created_by: string; + deleted_at: string | null; + description: string | null; + end_time: string | null; + id: string; + start_date: string; + start_time: string; + tablo_id: string; + title: string; + }; Insert: { - created_at?: string | null - created_by: string - deleted_at?: string | null - description?: string | null - end_time?: string | null - id?: string - start_date: string - start_time: string - tablo_id: string - title: string - } + created_at?: string | null; + created_by: string; + deleted_at?: string | null; + description?: string | null; + end_time?: string | null; + id?: string; + start_date: string; + start_time: string; + tablo_id: string; + title: string; + }; Update: { - created_at?: string | null - created_by?: string - deleted_at?: string | null - description?: string | null - end_time?: string | null - id?: string - start_date?: string - start_time?: string - tablo_id?: string - title?: string - } + created_at?: string | null; + created_by?: string; + deleted_at?: string | null; + description?: string | null; + end_time?: string | null; + id?: string; + start_date?: string; + start_time?: string; + tablo_id?: string; + title?: string; + }; Relationships: [ { - foreignKeyName: "fk_events_tablo_id" - columns: ["tablo_id"] - isOneToOne: false - referencedRelation: "events_and_tablos" - referencedColumns: ["tablo_id"] + foreignKeyName: "fk_events_tablo_id"; + columns: ["tablo_id"]; + isOneToOne: false; + referencedRelation: "events_and_tablos"; + referencedColumns: ["tablo_id"]; }, { - foreignKeyName: "fk_events_tablo_id" - columns: ["tablo_id"] - isOneToOne: false - referencedRelation: "tablos" - referencedColumns: ["id"] + foreignKeyName: "fk_events_tablo_id"; + columns: ["tablo_id"]; + isOneToOne: false; + referencedRelation: "tablos"; + referencedColumns: ["id"]; }, { - foreignKeyName: "fk_events_tablo_id" - columns: ["tablo_id"] - isOneToOne: false - referencedRelation: "user_tablos" - referencedColumns: ["id"] + foreignKeyName: "fk_events_tablo_id"; + columns: ["tablo_id"]; + isOneToOne: false; + referencedRelation: "user_tablos"; + referencedColumns: ["id"]; }, - ] - } + ]; + }; feedbacks: { Row: { - created_at: string | null - fd_type: string - id: number - message: string - user_id: string - } + created_at: string | null; + fd_type: string; + id: number; + message: string; + user_id: string; + }; Insert: { - created_at?: string | null - fd_type: string - id?: number - message: string - user_id: string - } + created_at?: string | null; + fd_type: string; + id?: number; + message: string; + user_id: string; + }; Update: { - created_at?: string | null - fd_type?: string - id?: number - message?: string - user_id?: string - } - Relationships: [] - } + created_at?: string | null; + fd_type?: string; + id?: number; + message?: string; + user_id?: string; + }; + Relationships: []; + }; note_access: { Row: { - created_at: string | null - id: number - is_active: boolean | null - note_id: string - tablo_id: string | null - updated_at: string | null - user_id: string - } + created_at: string | null; + id: number; + is_active: boolean | null; + note_id: string; + tablo_id: string | null; + updated_at: string | null; + user_id: string; + }; Insert: { - created_at?: string | null - id?: number - is_active?: boolean | null - note_id: string - tablo_id?: string | null - updated_at?: string | null - user_id: string - } + created_at?: string | null; + id?: number; + is_active?: boolean | null; + note_id: string; + tablo_id?: string | null; + updated_at?: string | null; + user_id: string; + }; Update: { - created_at?: string | null - id?: number - is_active?: boolean | null - note_id?: string - tablo_id?: string | null - updated_at?: string | null - user_id?: string - } + created_at?: string | null; + id?: number; + is_active?: boolean | null; + note_id?: string; + tablo_id?: string | null; + updated_at?: string | null; + user_id?: string; + }; Relationships: [ { - foreignKeyName: "fk_note_access_note_id" - columns: ["note_id"] - isOneToOne: false - referencedRelation: "notes" - referencedColumns: ["id"] + foreignKeyName: "fk_note_access_note_id"; + columns: ["note_id"]; + isOneToOne: false; + referencedRelation: "notes"; + referencedColumns: ["id"]; }, { - foreignKeyName: "fk_note_access_tablo_id" - columns: ["tablo_id"] - isOneToOne: false - referencedRelation: "events_and_tablos" - referencedColumns: ["tablo_id"] + foreignKeyName: "fk_note_access_tablo_id"; + columns: ["tablo_id"]; + isOneToOne: false; + referencedRelation: "events_and_tablos"; + referencedColumns: ["tablo_id"]; }, { - foreignKeyName: "fk_note_access_tablo_id" - columns: ["tablo_id"] - isOneToOne: false - referencedRelation: "tablos" - referencedColumns: ["id"] + foreignKeyName: "fk_note_access_tablo_id"; + columns: ["tablo_id"]; + isOneToOne: false; + referencedRelation: "tablos"; + referencedColumns: ["id"]; }, { - foreignKeyName: "fk_note_access_tablo_id" - columns: ["tablo_id"] - isOneToOne: false - referencedRelation: "user_tablos" - referencedColumns: ["id"] + foreignKeyName: "fk_note_access_tablo_id"; + columns: ["tablo_id"]; + isOneToOne: false; + referencedRelation: "user_tablos"; + referencedColumns: ["id"]; }, - ] - } + ]; + }; notes: { Row: { - content: string | null - created_at: string | null - deleted_at: string | null - id: string - title: string - updated_at: string | null - user_id: string - } + content: string | null; + created_at: string | null; + deleted_at: string | null; + id: string; + title: string; + updated_at: string | null; + user_id: string; + }; Insert: { - content?: string | null - created_at?: string | null - deleted_at?: string | null - id?: string - title: string - updated_at?: string | null - user_id: string - } + content?: string | null; + created_at?: string | null; + deleted_at?: string | null; + id?: string; + title: string; + updated_at?: string | null; + user_id: string; + }; Update: { - content?: string | null - created_at?: string | null - deleted_at?: string | null - id?: string - title?: string - updated_at?: string | null - user_id?: string - } - Relationships: [] - } + content?: string | null; + created_at?: string | null; + deleted_at?: string | null; + id?: string; + title?: string; + updated_at?: string | null; + user_id?: string; + }; + Relationships: []; + }; profiles: { Row: { - avatar_url: string | null - email: string | null - first_name: string | null - id: string - is_temporary: boolean - last_name: string | null - last_signed_in: string | null - name: string | null - plan: Database["public"]["Enums"]["subscription_plan"] | null - short_user_id: string - } + avatar_url: string | null; + email: string | null; + first_name: string | null; + id: string; + is_temporary: boolean; + last_name: string | null; + last_signed_in: string | null; + name: string | null; + plan: Database["public"]["Enums"]["subscription_plan"] | null; + short_user_id: string; + }; Insert: { - avatar_url?: string | null - email?: string | null - first_name?: string | null - id: string - is_temporary?: boolean - last_name?: string | null - last_signed_in?: string | null - name?: string | null - plan?: Database["public"]["Enums"]["subscription_plan"] | null - short_user_id: string - } + avatar_url?: string | null; + email?: string | null; + first_name?: string | null; + id: string; + is_temporary?: boolean; + last_name?: string | null; + last_signed_in?: string | null; + name?: string | null; + plan?: Database["public"]["Enums"]["subscription_plan"] | null; + short_user_id: string; + }; Update: { - avatar_url?: string | null - email?: string | null - first_name?: string | null - id?: string - is_temporary?: boolean - last_name?: string | null - last_signed_in?: string | null - name?: string | null - plan?: Database["public"]["Enums"]["subscription_plan"] | null - short_user_id?: string - } - Relationships: [] - } + avatar_url?: string | null; + email?: string | null; + first_name?: string | null; + id?: string; + is_temporary?: boolean; + last_name?: string | null; + last_signed_in?: string | null; + name?: string | null; + plan?: Database["public"]["Enums"]["subscription_plan"] | null; + short_user_id?: string; + }; + Relationships: []; + }; shared_notes: { Row: { - created_at: string | null - is_public: boolean | null - note_id: string - updated_at: string | null - user_id: string - } + created_at: string | null; + is_public: boolean | null; + note_id: string; + updated_at: string | null; + user_id: string; + }; Insert: { - created_at?: string | null - is_public?: boolean | null - note_id: string - updated_at?: string | null - user_id: string - } + created_at?: string | null; + is_public?: boolean | null; + note_id: string; + updated_at?: string | null; + user_id: string; + }; Update: { - created_at?: string | null - is_public?: boolean | null - note_id?: string - updated_at?: string | null - user_id?: string - } + created_at?: string | null; + is_public?: boolean | null; + note_id?: string; + updated_at?: string | null; + user_id?: string; + }; Relationships: [ { - foreignKeyName: "fk_shared_notes_note_id" - columns: ["note_id"] - isOneToOne: true - referencedRelation: "notes" - referencedColumns: ["id"] + foreignKeyName: "fk_shared_notes_note_id"; + columns: ["note_id"]; + isOneToOne: true; + referencedRelation: "notes"; + referencedColumns: ["id"]; }, - ] - } + ]; + }; tablo_access: { Row: { - created_at: string | null - granted_by: string - id: number - is_active: boolean | null - is_admin: boolean | null - tablo_id: string - user_id: string - } + created_at: string | null; + granted_by: string; + id: number; + is_active: boolean | null; + is_admin: boolean | null; + tablo_id: string; + user_id: string; + }; Insert: { - created_at?: string | null - granted_by: string - id?: number - is_active?: boolean | null - is_admin?: boolean | null - tablo_id: string - user_id: string - } + created_at?: string | null; + granted_by: string; + id?: number; + is_active?: boolean | null; + is_admin?: boolean | null; + tablo_id: string; + user_id: string; + }; Update: { - created_at?: string | null - granted_by?: string - id?: number - is_active?: boolean | null - is_admin?: boolean | null - tablo_id?: string - user_id?: string - } + created_at?: string | null; + granted_by?: string; + id?: number; + is_active?: boolean | null; + is_admin?: boolean | null; + tablo_id?: string; + user_id?: string; + }; Relationships: [ { - foreignKeyName: "fk_tablo_access_tablo_id" - columns: ["tablo_id"] - isOneToOne: false - referencedRelation: "events_and_tablos" - referencedColumns: ["tablo_id"] + foreignKeyName: "fk_tablo_access_tablo_id"; + columns: ["tablo_id"]; + isOneToOne: false; + referencedRelation: "events_and_tablos"; + referencedColumns: ["tablo_id"]; }, { - foreignKeyName: "fk_tablo_access_tablo_id" - columns: ["tablo_id"] - isOneToOne: false - referencedRelation: "tablos" - referencedColumns: ["id"] + foreignKeyName: "fk_tablo_access_tablo_id"; + columns: ["tablo_id"]; + isOneToOne: false; + referencedRelation: "tablos"; + referencedColumns: ["id"]; }, { - foreignKeyName: "fk_tablo_access_tablo_id" - columns: ["tablo_id"] - isOneToOne: false - referencedRelation: "user_tablos" - referencedColumns: ["id"] + foreignKeyName: "fk_tablo_access_tablo_id"; + columns: ["tablo_id"]; + isOneToOne: false; + referencedRelation: "user_tablos"; + referencedColumns: ["id"]; }, { - foreignKeyName: "fk_tablo_access_user_id_from_profiles" - columns: ["user_id"] - isOneToOne: false - referencedRelation: "profiles" - referencedColumns: ["id"] + foreignKeyName: "fk_tablo_access_user_id_from_profiles"; + columns: ["user_id"]; + isOneToOne: false; + referencedRelation: "profiles"; + referencedColumns: ["id"]; }, - ] - } + ]; + }; tablo_invites: { Row: { - created_at: string - id: number - invite_token: string - invited_by: string - invited_email: string - is_pending: boolean - tablo_id: string - } + created_at: string; + id: number; + invite_token: string; + invited_by: string; + invited_email: string; + is_pending: boolean; + tablo_id: string; + }; Insert: { - created_at?: string - id?: number - invite_token: string - invited_by: string - invited_email: string - is_pending?: boolean - tablo_id: string - } + created_at?: string; + id?: number; + invite_token: string; + invited_by: string; + invited_email: string; + is_pending?: boolean; + tablo_id: string; + }; Update: { - created_at?: string - id?: number - invite_token?: string - invited_by?: string - invited_email?: string - is_pending?: boolean - tablo_id?: string - } + created_at?: string; + id?: number; + invite_token?: string; + invited_by?: string; + invited_email?: string; + is_pending?: boolean; + tablo_id?: string; + }; Relationships: [ { - foreignKeyName: "fk_tablo_invitations_tablo_id" - columns: ["tablo_id"] - isOneToOne: false - referencedRelation: "events_and_tablos" - referencedColumns: ["tablo_id"] + foreignKeyName: "fk_tablo_invitations_tablo_id"; + columns: ["tablo_id"]; + isOneToOne: false; + referencedRelation: "events_and_tablos"; + referencedColumns: ["tablo_id"]; }, { - foreignKeyName: "fk_tablo_invitations_tablo_id" - columns: ["tablo_id"] - isOneToOne: false - referencedRelation: "tablos" - referencedColumns: ["id"] + foreignKeyName: "fk_tablo_invitations_tablo_id"; + columns: ["tablo_id"]; + isOneToOne: false; + referencedRelation: "tablos"; + referencedColumns: ["id"]; }, { - foreignKeyName: "fk_tablo_invitations_tablo_id" - columns: ["tablo_id"] - isOneToOne: false - referencedRelation: "user_tablos" - referencedColumns: ["id"] + foreignKeyName: "fk_tablo_invitations_tablo_id"; + columns: ["tablo_id"]; + isOneToOne: false; + referencedRelation: "user_tablos"; + referencedColumns: ["id"]; }, - ] - } + ]; + }; tablos: { Row: { - color: string | null - created_at: string | null - deleted_at: string | null - id: string - image: string | null - name: string - owner_id: string - position: number - status: string - updated_at: string | null - } + color: string | null; + created_at: string | null; + deleted_at: string | null; + id: string; + image: string | null; + name: string; + owner_id: string; + position: number; + status: string; + updated_at: string | null; + }; Insert: { - color?: string | null - created_at?: string | null - deleted_at?: string | null - id?: string - image?: string | null - name: string - owner_id: string - position?: number - status?: string - updated_at?: string | null - } + color?: string | null; + created_at?: string | null; + deleted_at?: string | null; + id?: string; + image?: string | null; + name: string; + owner_id: string; + position?: number; + status?: string; + updated_at?: string | null; + }; Update: { - color?: string | null - created_at?: string | null - deleted_at?: string | null - id?: string - image?: string | null - name?: string - owner_id?: string - position?: number - status?: string - updated_at?: string | null - } - Relationships: [] - } + color?: string | null; + created_at?: string | null; + deleted_at?: string | null; + id?: string; + image?: string | null; + name?: string; + owner_id?: string; + position?: number; + status?: string; + updated_at?: string | null; + }; + Relationships: []; + }; user_introductions: { Row: { - config: Json - created_at: string | null - updated_at: string | null - user_id: string - } + config: Json; + created_at: string | null; + updated_at: string | null; + user_id: string; + }; Insert: { - config?: Json - created_at?: string | null - updated_at?: string | null - user_id: string - } + config?: Json; + created_at?: string | null; + updated_at?: string | null; + user_id: string; + }; Update: { - config?: Json - created_at?: string | null - updated_at?: string | null - user_id?: string - } - Relationships: [] - } - } + config?: Json; + created_at?: string | null; + updated_at?: string | null; + user_id?: string; + }; + Relationships: []; + }; + }; Views: { events_and_tablos: { Row: { - description: string | null - end_time: string | null - event_id: string | null - start_date: string | null - start_time: string | null - tablo_color: string | null - tablo_id: string | null - tablo_name: string | null - tablo_status: string | null - title: string | null - } - Relationships: [] - } + description: string | null; + end_time: string | null; + event_id: string | null; + start_date: string | null; + start_time: string | null; + tablo_color: string | null; + tablo_id: string | null; + tablo_name: string | null; + tablo_status: string | null; + title: string | null; + }; + Relationships: []; + }; user_tablos: { Row: { - access_level: string | null - color: string | null - created_at: string | null - deleted_at: string | null - id: string | null - image: string | null - is_admin: boolean | null - name: string | null - position: number | null - status: string | null - user_id: string | null - } + access_level: string | null; + color: string | null; + created_at: string | null; + deleted_at: string | null; + id: string | null; + image: string | null; + is_admin: boolean | null; + name: string | null; + position: number | null; + status: string | null; + user_id: string | null; + }; Relationships: [ { - foreignKeyName: "fk_tablo_access_user_id_from_profiles" - columns: ["user_id"] - isOneToOne: false - referencedRelation: "profiles" - referencedColumns: ["id"] + foreignKeyName: "fk_tablo_access_user_id_from_profiles"; + columns: ["user_id"]; + isOneToOne: false; + referencedRelation: "profiles"; + referencedColumns: ["id"]; }, - ] - } - } + ]; + }; + }; Functions: { - generate_random_string: { Args: { length?: number }; Returns: string } + generate_random_string: { Args: { length?: number }; Returns: string }; get_my_active_subscription: { - Args: never + Args: never; Returns: { - billing_interval: string - cancel_at_period_end: boolean - currency: string - current_period_end: string - current_period_start: string - first_name: string - last_name: string - plan: Database["public"]["Enums"]["subscription_plan"] - product_name: string - status: string - subscription_id: string - unit_amount: number - user_email: string - user_id: string - }[] - } + billing_interval: string; + cancel_at_period_end: boolean; + currency: string; + current_period_end: string; + current_period_start: string; + first_name: string; + last_name: string; + plan: Database["public"]["Enums"]["subscription_plan"]; + product_name: string; + status: string; + subscription_id: string; + unit_amount: number; + user_email: string; + user_id: string; + }[]; + }; get_stripe_prices: { - Args: never + Args: never; Returns: { - active: boolean - created: number - currency: string - id: string - metadata: Json - product: string - recurring: Json - unit_amount: number - }[] - } + active: boolean; + created: number; + currency: string; + id: string; + metadata: Json; + product: string; + recurring: Json; + unit_amount: number; + }[]; + }; get_stripe_products: { - Args: never + Args: never; Returns: { - active: boolean - created: number - description: string - id: string - metadata: Json - name: string - }[] - } + active: boolean; + created: number; + description: string; + id: string; + metadata: Json; + name: string; + }[]; + }; get_user_stripe_customer: { - Args: never + Args: never; Returns: { - created: number - email: string - id: string - metadata: Json - user_id: string - }[] - } + created: number; + email: string; + id: string; + metadata: Json; + user_id: string; + }[]; + }; get_user_stripe_customer_id: { - Args: { user_uuid: string } - Returns: string - } + Args: { user_uuid: string }; + Returns: string; + }; get_user_stripe_subscriptions: { - Args: never + Args: never; Returns: { - cancel_at_period_end: boolean - canceled_at: number - created: number - current_period_end: number - current_period_start: number - customer: string - id: string - metadata: Json - price_id: string - quantity: number - status: string - trial_end: Json - trial_start: Json - user_id: string - }[] - } + cancel_at_period_end: boolean; + canceled_at: number; + created: number; + current_period_end: number; + current_period_start: number; + customer: string; + id: string; + metadata: Json; + price_id: string; + quantity: number; + status: string; + trial_end: Json; + trial_start: Json; + user_id: string; + }[]; + }; get_user_subscription_status: { - Args: { user_uuid: string } + Args: { user_uuid: string }; Returns: { - cancel_at_period_end: boolean - current_period_end: number - current_period_start: number - plan: Database["public"]["Enums"]["subscription_plan"] - price_id: string - product_name: string - status: string - subscription_id: string - }[] - } - is_paying_user: { Args: { user_uuid: string }; Returns: boolean } - } + cancel_at_period_end: boolean; + current_period_end: number; + current_period_start: number; + plan: Database["public"]["Enums"]["subscription_plan"]; + price_id: string; + product_name: string; + status: string; + subscription_id: string; + }[]; + }; + is_paying_user: { Args: { user_uuid: string }; Returns: boolean }; + }; Enums: { - devis_status: "draft" | "sent" | "accepted" | "rejected" | "expired" - subscription_plan: "none" | "trial" | "standard" - } + devis_status: "draft" | "sent" | "accepted" | "rejected" | "expired"; + subscription_plan: "none" | "trial" | "standard"; + }; CompositeTypes: { time_range: { - start_time: string | null - end_time: string | null - } - } - } -} + start_time: string | null; + end_time: string | null; + }; + }; + }; +}; -type DatabaseWithoutInternals = Omit +type DatabaseWithoutInternals = Omit; -type DefaultSchema = DatabaseWithoutInternals[Extract] +type DefaultSchema = DatabaseWithoutInternals[Extract]; export type Tables< DefaultSchemaTableNameOrOptions extends | keyof (DefaultSchema["Tables"] & DefaultSchema["Views"]) | { schema: keyof DatabaseWithoutInternals }, TableName extends DefaultSchemaTableNameOrOptions extends { - schema: keyof DatabaseWithoutInternals + schema: keyof DatabaseWithoutInternals; } ? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"]) : never = never, > = DefaultSchemaTableNameOrOptions extends { - schema: keyof DatabaseWithoutInternals + schema: keyof DatabaseWithoutInternals; } ? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends { - Row: infer R + Row: infer R; } ? R : never - : DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] & - DefaultSchema["Views"]) - ? (DefaultSchema["Tables"] & - DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends { - Row: infer R + : DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] & DefaultSchema["Views"]) + ? (DefaultSchema["Tables"] & DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends { + Row: infer R; } ? R : never - : never + : never; export type TablesInsert< DefaultSchemaTableNameOrOptions extends | keyof DefaultSchema["Tables"] | { schema: keyof DatabaseWithoutInternals }, TableName extends DefaultSchemaTableNameOrOptions extends { - schema: keyof DatabaseWithoutInternals + schema: keyof DatabaseWithoutInternals; } ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] : never = never, > = DefaultSchemaTableNameOrOptions extends { - schema: keyof DatabaseWithoutInternals + schema: keyof DatabaseWithoutInternals; } ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends { - Insert: infer I + Insert: infer I; } ? I : never : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends { - Insert: infer I + Insert: infer I; } ? I : never - : never + : never; export type TablesUpdate< DefaultSchemaTableNameOrOptions extends | keyof DefaultSchema["Tables"] | { schema: keyof DatabaseWithoutInternals }, TableName extends DefaultSchemaTableNameOrOptions extends { - schema: keyof DatabaseWithoutInternals + schema: keyof DatabaseWithoutInternals; } ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] : never = never, > = DefaultSchemaTableNameOrOptions extends { - schema: keyof DatabaseWithoutInternals + schema: keyof DatabaseWithoutInternals; } ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends { - Update: infer U + Update: infer U; } ? U : never : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends { - Update: infer U + Update: infer U; } ? U : never - : never + : never; export type Enums< DefaultSchemaEnumNameOrOptions extends | keyof DefaultSchema["Enums"] | { schema: keyof DatabaseWithoutInternals }, EnumName extends DefaultSchemaEnumNameOrOptions extends { - schema: keyof DatabaseWithoutInternals + schema: keyof DatabaseWithoutInternals; } ? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"] : never = never, > = DefaultSchemaEnumNameOrOptions extends { - schema: keyof DatabaseWithoutInternals + schema: keyof DatabaseWithoutInternals; } ? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName] : DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"] ? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions] - : never + : never; export type CompositeTypes< PublicCompositeTypeNameOrOptions extends | keyof DefaultSchema["CompositeTypes"] | { schema: keyof DatabaseWithoutInternals }, CompositeTypeName extends PublicCompositeTypeNameOrOptions extends { - schema: keyof DatabaseWithoutInternals + schema: keyof DatabaseWithoutInternals; } ? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"] : never = never, > = PublicCompositeTypeNameOrOptions extends { - schema: keyof DatabaseWithoutInternals + schema: keyof DatabaseWithoutInternals; } ? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName] : PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"] ? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions] - : never + : never; export const Constants = { public: { @@ -859,4 +851,4 @@ export const Constants = { subscription_plan: ["none", "trial", "standard"], }, }, -} as const +} as const; diff --git a/api/src/helpers.ts b/api/src/helpers.ts index 8127b08..fa3606b 100644 --- a/api/src/helpers.ts +++ b/api/src/helpers.ts @@ -1,7 +1,7 @@ import { ListObjectsV2Command, PutObjectCommand, S3Client } from "@aws-sdk/client-s3"; import type { SupabaseClient } from "@supabase/supabase-js"; -import type { EventAndTablo } from "./types.ts"; import type { Context, Next } from "hono"; +import type { EventAndTablo } from "./types.ts"; export const generateICSFromEvents = ( events: EventAndTablo[], diff --git a/api/src/index.ts b/api/src/index.ts index 6f3e6be..910d09a 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -1,13 +1,18 @@ import { serve } from "@hono/node-server"; +import tracer from "dd-trace"; import { Hono } from "hono"; import { cors } from "hono/cors"; import { logger } from "hono/logger"; import path from "path"; +import Stripe from "stripe"; import { fileURLToPath } from "url"; -import { config } from "./config.js"; -import { publicRouter } from "./public.js"; -import { mainRouter } from "./routers.js"; -import tracer from "dd-trace"; +import { createConfig } from "./config.js"; +import { initializeMiddlewares } from "./middleware.js"; +import { getPublicRouter } from "./public.js"; +import { getMainRouter } from "./routers.js"; +import { loadSecrets, type Secrets } from "./secrets.js"; +import { createStripeSync } from "./stripeSync.js"; +import { createTransporter } from "./transporter.js"; tracer.init({ logInjection: true, @@ -16,60 +21,69 @@ tracer.init({ const __filename = fileURLToPath(import.meta.url); // get the resolved path to the file const __dirname = path.dirname(__filename); // get the name of the directory -const app = new Hono(); +async function startServer(secrets: Secrets) { + // Load configuration (will load from Google Secret Manager in production/staging) + console.log("Initializing application configuration..."); + const config = createConfig(secrets); + console.log(`✓ Configuration initialized for ${config.NODE_ENV} environment`); -app.use(logger()); + // Initialize middlewares + const middlewares = initializeMiddlewares(config); -app.use("*", async (c, next) => { - const corsMiddleware = cors({ - origin: config.CORS_ORIGIN.split(","), - allowHeaders: [ - "Authorization", - "Content-Type", - "Access-Control-Allow-Origin", - "Access-Control-Allow-Credentials", - "Access-Control-Expose-Headers", - ], - allowMethods: ["GET", "POST", "PATCH", "OPTIONS", "DELETE"], - exposeHeaders: ["set-cookie"], - credentials: true, + // Initialize Stripe client + const stripe = new Stripe(config.STRIPE_SECRET_KEY || "", { + apiVersion: "2025-10-29.clover", }); - return corsMiddleware(c, next); -}); + // Initialize Stripe Sync + const stripeSync = createStripeSync(config); -app.route("/api/v1", mainRouter); -app.route("/api/public", publicRouter); + // Initialize transporter + const transporter = createTransporter(config); -// const worker = async () => { -// const connectionString = `${ -// config.SUPABASE_CONNECTION_STRING -// }?ssl=true&sslrootcert=${path.resolve(__dirname, "..", "supabase_ca.crt")}`; + const app = new Hono(); -// const runner = await run({ -// connectionString, -// concurrency: 1, -// pollInterval: 1000, -// taskDirectory: path.resolve(__dirname, "tasks"), -// noPreparedStatements: true, -// crontabFile: path.resolve(__dirname, "crontab"), -// }); + app.use(logger()); -// await runner.promise; -// }; + app.use("*", async (c, next) => { + const corsMiddleware = cors({ + origin: config.CORS_ORIGIN.split(","), + allowHeaders: [ + "Authorization", + "Content-Type", + "Access-Control-Allow-Origin", + "Access-Control-Allow-Credentials", + "Access-Control-Expose-Headers", + ], + allowMethods: ["GET", "POST", "PATCH", "OPTIONS", "DELETE"], + exposeHeaders: ["set-cookie"], + credentials: true, + }); -// worker().catch((err) => { -// console.error(err); -// process.exit(1); -// }); + return corsMiddleware(c, next); + }); -serve( - { - fetch: app.fetch, - port: 8080, - }, - (info) => { - console.log(`Server is running on http://localhost:${info.port}`); - } -); -// TODO: Add health check endpoint + app.route("/api/v1", getMainRouter(middlewares, config, stripe, stripeSync, transporter)); + app.route("/api/public", getPublicRouter(middlewares)); + + serve( + { + fetch: app.fetch, + port: 8080, + }, + (info) => { + console.log(`✓ Server is running on http://localhost:${info.port}`); + } + ); + // TODO: Add health check endpoint +} + +loadSecrets() + .then((secrets) => { + console.log("Secrets loaded successfully"); + startServer(secrets); + }) + .catch((error) => { + console.error("Failed to load secrets:", error); + process.exit(1); + }); diff --git a/api/src/middleware.ts b/api/src/middleware.ts index 7210da3..b850caa 100644 --- a/api/src/middleware.ts +++ b/api/src/middleware.ts @@ -1,73 +1,109 @@ import { S3Client } from "@aws-sdk/client-s3"; -import { createClient, type User } from "@supabase/supabase-js"; -import type { Context, Next } from "hono"; +import { createClient, type SupabaseClient, type User } from "@supabase/supabase-js"; +import type { Context, MiddlewareHandler, Next } from "hono"; +import { createMiddleware } from "hono/factory"; import { StreamChat } from "stream-chat"; -import { config } from "./config.js"; +import { type AppConfig } from "./config.js"; -// Create authentication middleware -export const authMiddleware = async (c: Context, next: Next) => { - const supabase = c.get("supabase"); - // Extract Bearer token from Authorization header - const authHeader = c.req.header("Authorization"); - if (!authHeader || !authHeader.startsWith("Bearer ")) { - return c.json({ error: "Missing or invalid authorization header" }, 401); - } - - const token = authHeader.substring(7); // Remove "Bearer " prefix - - const { - data: { user }, - error, - } = await supabase.auth.getUser(token); - - if (error || !user) { - return c.json({ error: "Invalid or expired token" }, 401); - } - - const userTyped = user as User; - - c.set("user", userTyped); - await next(); +export type Middlewares = { + supabaseMiddleware: MiddlewareHandler<{ + Variables: { supabase: SupabaseClient }; + Bindings: { user: User }; + }>; + authMiddleware: MiddlewareHandler<{ + Variables: { supabase: SupabaseClient; user: User }; + Bindings: { user: User }; + }>; + streamChatMiddleware: MiddlewareHandler<{ + Variables: { streamServerClient: StreamChat }; + }>; + r2Middleware: MiddlewareHandler<{ + Variables: { s3_client: S3Client }; + }>; + regularUserCheckMiddleware: MiddlewareHandler<{ + Variables: { supabase: SupabaseClient; user: User }; + Bindings: { user: User }; + }>; }; -export const regularUserCheckMiddleware = async (c: Context, next: Next) => { - const user = c.get("user"); - if (user.is_temporary) { - return c.json({ error: "User is read only" }, 401); - } - await next(); -}; - -export const supabaseMiddleware = async (c: Context, next: Next) => { - const supabase = createClient( - process.env.SUPABASE_URL as string, - process.env.SUPABASE_SERVICE_ROLE_KEY as string - ); - c.set("supabase", supabase); - await next(); -}; - -export const streamChatMiddleware = async (c: Context, next: Next) => { - const serverClient = StreamChat.getInstance( - process.env.STREAM_CHAT_API_KEY as string, - process.env.STREAM_CHAT_API_SECRET as string, - { - disableCache: true, - } - ); - c.set("streamServerClient", serverClient); - await next(); -}; - -export const r2Middleware = async (c: Context, next: Next) => { - const s3 = new S3Client({ - region: "auto", - endpoint: `https://${config.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`, - credentials: { - accessKeyId: config.R2_ACCESS_KEY_ID, - secretAccessKey: config.R2_SECRET_ACCESS_KEY, - }, +export const initializeMiddlewares = (config: AppConfig): Middlewares => { + const supabaseMiddleware = createMiddleware(async (c: Context, next: Next) => { + const supabase = createClient(config.SUPABASE_URL, config.SUPABASE_SERVICE_ROLE_KEY); + c.set("supabase", supabase); + await next(); }); - c.set("s3_client", s3); - await next(); + + const authMiddleware = createMiddleware<{ + Variables: { supabase: SupabaseClient; user: User }; + Bindings: { user: User }; + }>(async (c, next) => { + const supabase = c.get("supabase"); + const authHeader = c.req.header("Authorization"); + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return c.json({ error: "Missing or invalid authorization header" }, 401); + } + const token = authHeader.substring(7); // Remove "Bearer " prefix + const { + data: { user }, + error, + } = await supabase.auth.getUser(token); + if (error || !user) { + return c.json({ error: "Invalid or expired token" }, 401); + } + c.set("user", user); + await next(); + }); + + const streamChatMiddleware = createMiddleware(async (c: Context, next: Next) => { + const serverClient = StreamChat.getInstance( + config.STREAM_CHAT_API_KEY, + config.STREAM_CHAT_API_SECRET + ); + c.set("streamServerClient", serverClient); + await next(); + }); + + const r2Middleware = createMiddleware(async (c: Context, next: Next) => { + const s3 = new S3Client({ + region: "auto", + endpoint: `https://${config.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`, + credentials: { + accessKeyId: config.R2_ACCESS_KEY_ID, + secretAccessKey: config.R2_SECRET_ACCESS_KEY, + }, + }); + c.set("s3_client", s3); + await next(); + }); + + const regularUserCheckMiddleware = createMiddleware<{ + Variables: { supabase: SupabaseClient; user: User }; + Bindings: { user: User }; + }>(async (c, next) => { + const supabase = c.get("supabase"); + const user = c.get("user"); + + const { data: profile, error } = await supabase + .from("profiles") + .select("is_temporary") + .eq("id", user.id) + .single(); + + if (error || !profile) { + return c.json({ error: error.message }, 500); + } + + if (profile.is_temporary) { + return c.json({ error: "User is read only" }, 401); + } + await next(); + }); + + return { + supabaseMiddleware, + authMiddleware, + streamChatMiddleware, + r2Middleware, + regularUserCheckMiddleware, + }; }; diff --git a/api/src/notes.ts b/api/src/notes.ts index 0d23e82..483e80c 100644 --- a/api/src/notes.ts +++ b/api/src/notes.ts @@ -1,54 +1,55 @@ import type { SupabaseClient, User } from "@supabase/supabase-js"; import { Hono } from "hono"; -import { checkTabloMember } from "./helpers.js"; import type { Database } from "./database.types.js"; -import { authMiddleware } from "./middleware.js"; +import { checkTabloMember } from "./helpers.js"; +import type { Middlewares } from "./middleware.js"; -export const notesRouter = new Hono<{ - Variables: { - user: User; - supabase: SupabaseClient; - }; -}>(); +export const getNotesRouter = (middlewares: Middlewares) => { + const notesRouter = new Hono<{ + Variables: { + user: User; + supabase: SupabaseClient; + }; + }>(); -type Note = Database["public"]["Tables"]["notes"]["Row"]; + type Note = Database["public"]["Tables"]["notes"]["Row"]; -notesRouter.use(authMiddleware); + notesRouter.use(middlewares.authMiddleware); -/** - * Fetch notes shared with a specific tablo - */ -notesRouter.get("/:tabloId", checkTabloMember, async (c) => { - const { tabloId } = c.req.param(); + /** + * Fetch notes shared with a specific tablo + */ + notesRouter.get("/:tabloId", checkTabloMember, async (c) => { + const { tabloId } = c.req.param(); - if (!tabloId) { - return c.json({ error: "Tablo ID is required" }, 400); - } + if (!tabloId) { + return c.json({ error: "Tablo ID is required" }, 400); + } - const supabase = c.get("supabase"); + const supabase = c.get("supabase"); - // Find the tablo owner - const { data: tabloData, error: tabloError } = await supabase - .from("tablos") - .select("owner_id") - .eq("id", tabloId) - .single(); + // Find the tablo owner + const { data: tabloData, error: tabloError } = await supabase + .from("tablos") + .select("owner_id") + .eq("id", tabloId) + .single(); - if (tabloError) { - console.error("Error fetching tablo:", tabloError); - return c.json({ error: "Failed to fetch tablo" }, 500); - } + if (tabloError) { + console.error("Error fetching tablo:", tabloError); + return c.json({ error: "Failed to fetch tablo" }, 500); + } - if (!tabloData) { - return c.json({ error: "Tablo not found" }, 404); - } + if (!tabloData) { + return c.json({ error: "Tablo not found" }, 404); + } - const tabloOwnerId = tabloData.owner_id; + const tabloOwnerId = tabloData.owner_id; - // Find notes shared with this specific tablo or all tablos - const { data, error } = await supabase - .from("note_access") - .select(` + // Find notes shared with this specific tablo or all tablos + const { data, error } = await supabase + .from("note_access") + .select(` note_id, notes!inner ( id, @@ -60,23 +61,26 @@ notesRouter.get("/:tabloId", checkTabloMember, async (c) => { deleted_at ) `) - .eq("is_active", true) - .eq("user_id", tabloOwnerId) - .or(`tablo_id.eq.${tabloId},tablo_id.is.null`) - .is("notes.deleted_at", null); + .eq("is_active", true) + .eq("user_id", tabloOwnerId) + .or(`tablo_id.eq.${tabloId},tablo_id.is.null`) + .is("notes.deleted_at", null); - if (error) { - return c.json({ error: "An error occurred" }, 500); - } + if (error) { + return c.json({ error: "An error occurred" }, 500); + } - // Extract notes from the join result and remove duplicates - type JoinedResult = { note_id: string; notes: Note[] }; - const extractedNotes = (data as JoinedResult[]) - .map((item) => (Array.isArray(item.notes) ? item.notes[0] : item.notes)) - .filter((note) => note !== null && note !== undefined); + // Extract notes from the join result and remove duplicates + type JoinedResult = { note_id: string; notes: Note[] }; + const extractedNotes = (data as JoinedResult[]) + .map((item) => (Array.isArray(item.notes) ? item.notes[0] : item.notes)) + .filter((note) => note !== null && note !== undefined); - // Remove duplicates by note id (in case a note is shared both with all tablos and this specific tablo) - const uniqueNotes = Array.from(new Map(extractedNotes.map((note) => [note.id, note])).values()); + // Remove duplicates by note id (in case a note is shared both with all tablos and this specific tablo) + const uniqueNotes = Array.from(new Map(extractedNotes.map((note) => [note.id, note])).values()); - return c.json({ notes: uniqueNotes }); -}); + return c.json({ notes: uniqueNotes }); + }); + + return notesRouter; +}; diff --git a/api/src/public.ts b/api/src/public.ts index 9b8bae4..a2e85a2 100644 --- a/api/src/public.ts +++ b/api/src/public.ts @@ -1,7 +1,7 @@ import type { SupabaseClient } from "@supabase/supabase-js"; import { Hono } from "hono"; import type { Database, Tables } from "./database.types.js"; -import { supabaseMiddleware } from "./middleware.js"; +import type { Middlewares } from "./middleware.js"; import { type EventTypeConfig, type Exception, @@ -12,119 +12,123 @@ import { type WeeklyAvailability, } from "./slots.js"; -export const publicRouter = new Hono<{ - Variables: { - supabase: SupabaseClient; - }; -}>(); +export const getPublicRouter = (middlewares: Middlewares) => { + const publicRouter = new Hono<{ + Variables: { + supabase: SupabaseClient; + }; + }>(); -publicRouter.use(supabaseMiddleware); + publicRouter.use(middlewares.supabaseMiddleware); -publicRouter.get("/slots/:shortUserId/:standardName", async (c) => { - const supabase = c.get("supabase"); - const shortUserId = c.req.param("shortUserId"); - const standardName = c.req.param("standardName"); + publicRouter.get("/slots/:shortUserId/:standardName", async (c) => { + const supabase = c.get("supabase"); + const shortUserId = c.req.param("shortUserId"); + const standardName = c.req.param("standardName"); - // Get user - const { data: userData, error: userError } = await supabase - .from("profiles") - .select("*") - .eq("short_user_id", shortUserId) - .single(); + // Get user + const { data: userData, error: userError } = await supabase + .from("profiles") + .select("*") + .eq("short_user_id", shortUserId) + .single(); - if (userError || !userData) { - return c.json({ error: "User not found" }, 404); - } - - const user = userData as Tables<"profiles">; - - // Get event type - const { data: eventTypeData, error: eventTypeError } = await supabase - .from("event_types") - .select("*") - .eq("user_id", user.id) - .eq("standard_name", standardName) - .is("deleted_at", null) - .single(); - - if (eventTypeError || !eventTypeData) { - return c.json({ error: "Event type not found" }, 404); - } - - const eventType = eventTypeData as Database["public"]["Tables"]["event_types"]["Row"]; - const eventTypeConfig = eventType.config as EventTypeConfig; - - // Get user's availabilities - const { data: availabilitiesData, error: availabilitiesError } = await supabase - .from("availabilities") - .select("*") - .eq("user_id", user.id) - .single(); - - if (availabilitiesError) { - return c.json({ error: "Availabilities not found" }, 404); - } - - const availabilities = availabilitiesData as Tables<"availabilities">; - const weeklyAvailability = availabilities.availability_data as WeeklyAvailability; - const exceptions = (availabilities.exceptions as Exception[]) || []; - - // Get existing events for the next month - // Use CET time for availability calculations - const now = new Date(); - const nextMonth = new Date(now); - nextMonth.setMonth(now.getMonth() + 2); - - const { data: eventsData, error: eventsError } = await supabase - .from("events") - .select("*") - .eq("created_by", user.id) - .gte("start_date", getDateStringCET(now)) - .lte("start_date", getDateStringCET(nextMonth)) - .is("deleted_at", null); - - if (eventsError) { - return c.json({ error: "Failed to fetch events" }, 500); - } - - const existingEvents = eventsData as Tables<"events">[]; - - // Generate slots for the next month - const slots: TimeSlot[] = []; - const currentDate = new Date(now); - - while (currentDate <= nextMonth) { - const dayOfWeek = getDayOfWeek(currentDate); - const dayAvailability = weeklyAvailability[dayOfWeek]; - - if (dayAvailability) { - const daySlots = generateTimeSlots( - now, // Pass CET current time as first parameter - currentDate, - dayAvailability, - eventTypeConfig, - exceptions, - existingEvents - ); - slots.push(...daySlots); + if (userError || !userData) { + return c.json({ error: "User not found" }, 404); } - currentDate.setDate(currentDate.getDate() + 1); - } + const user = userData as Tables<"profiles">; - // Group slots by date for easier frontend consumption - const slotsByDate: { [date: string]: TimeSlot[] } = {}; - slots.forEach((slot) => { - if (!slotsByDate[slot.date]) { - slotsByDate[slot.date] = []; + // Get event type + const { data: eventTypeData, error: eventTypeError } = await supabase + .from("event_types") + .select("*") + .eq("user_id", user.id) + .eq("standard_name", standardName) + .is("deleted_at", null) + .single(); + + if (eventTypeError || !eventTypeData) { + return c.json({ error: "Event type not found" }, 404); } - slotsByDate[slot.date].push(slot); + + const eventType = eventTypeData as Database["public"]["Tables"]["event_types"]["Row"]; + const eventTypeConfig = eventType.config as EventTypeConfig; + + // Get user's availabilities + const { data: availabilitiesData, error: availabilitiesError } = await supabase + .from("availabilities") + .select("*") + .eq("user_id", user.id) + .single(); + + if (availabilitiesError) { + return c.json({ error: "Availabilities not found" }, 404); + } + + const availabilities = availabilitiesData as Tables<"availabilities">; + const weeklyAvailability = availabilities.availability_data as WeeklyAvailability; + const exceptions = (availabilities.exceptions as Exception[]) || []; + + // Get existing events for the next month + // Use CET time for availability calculations + const now = new Date(); + const nextMonth = new Date(now); + nextMonth.setMonth(now.getMonth() + 2); + + const { data: eventsData, error: eventsError } = await supabase + .from("events") + .select("*") + .eq("created_by", user.id) + .gte("start_date", getDateStringCET(now)) + .lte("start_date", getDateStringCET(nextMonth)) + .is("deleted_at", null); + + if (eventsError) { + return c.json({ error: "Failed to fetch events" }, 500); + } + + const existingEvents = eventsData as Tables<"events">[]; + + // Generate slots for the next month + const slots: TimeSlot[] = []; + const currentDate = new Date(now); + + while (currentDate <= nextMonth) { + const dayOfWeek = getDayOfWeek(currentDate); + const dayAvailability = weeklyAvailability[dayOfWeek]; + + if (dayAvailability) { + const daySlots = generateTimeSlots( + now, // Pass CET current time as first parameter + currentDate, + dayAvailability, + eventTypeConfig, + exceptions, + existingEvents + ); + slots.push(...daySlots); + } + + currentDate.setDate(currentDate.getDate() + 1); + } + + // Group slots by date for easier frontend consumption + const slotsByDate: { [date: string]: TimeSlot[] } = {}; + slots.forEach((slot) => { + if (!slotsByDate[slot.date]) { + slotsByDate[slot.date] = []; + } + slotsByDate[slot.date].push(slot); + }); + + return c.json({ + user: { name: user.name, avatar_url: user.avatar_url }, + eventType: eventTypeConfig, + slots: slotsByDate, + availableSlots: slots.filter((slot) => slot.available), + }); }); - return c.json({ - user: { name: user.name, avatar_url: user.avatar_url }, - eventType: eventTypeConfig, - slots: slotsByDate, - availableSlots: slots.filter((slot) => slot.available), - }); -}); + return publicRouter; +}; diff --git a/api/src/routers.ts b/api/src/routers.ts index 46b2122..c79e632 100644 --- a/api/src/routers.ts +++ b/api/src/routers.ts @@ -1,42 +1,39 @@ +import type { StripeSync } from "@supabase/stripe-sync-engine"; import { Hono } from "hono"; -import { supabaseMiddleware } from "./middleware.js"; -import { tabloRouter } from "./tablo.js"; -import { tabloDataRouter } from "./tablo_data.js"; -import { taskRouter } from "./tasks.js"; -import { userRouter } from "./user.js"; -import { notesRouter } from "./notes.js"; -import { stripeRouter, stripeWebhookRouter } from "./stripe.js"; +import type { Transporter } from "nodemailer"; +import type Stripe from "stripe"; +import type { AppConfig } from "./config.js"; +import type { Middlewares } from "./middleware.js"; +import { getNotesRouter } from "./notes.js"; +import { getStripeRouter, getStripeWebhookRouter } from "./stripe.js"; +import { getTabloRouter } from "./tablo.js"; +import { getTabloDataRouter } from "./tablo_data.js"; +import { getTaskRouter } from "./tasks.js"; +import { getUserRouter } from "./user.js"; -export const mainRouter = new Hono<{ - Bindings: { - SESSION_ENCRYPTION_KEY: string; - }; -}>(); +export const getMainRouter = ( + middlewares: Middlewares, + config: AppConfig, + stripe: Stripe, + stripeSync: StripeSync, + transporter: Transporter +) => { + const mainRouter = new Hono<{ + Bindings: { + SESSION_ENCRYPTION_KEY: string; + }; + }>(); -// const store = new CookieStore(); + mainRouter.use(middlewares.supabaseMiddleware); -mainRouter.use(supabaseMiddleware); -// mainRouter.use("*", (c, next) => -// sessionMiddleware({ -// store, -// encryptionKey: c.env.SESSION_ENCRYPTION_KEY, -// expireAfterSeconds: 900, -// sessionCookieName: "xtablo_session", -// cookieOptions: { -// sameSite: "Lax", -// path: "/", -// httpOnly: true, -// secure: false, -// // secure: process.env.NODE_ENV === "production", -// }, -// })(c, next) -// ); + mainRouter.route("/users", getUserRouter(middlewares, transporter)); + mainRouter.route("/tablos", getTabloRouter(middlewares, config, transporter)); + mainRouter.route("/tasks", getTaskRouter(middlewares, config, stripeSync)); + mainRouter.route("/tablo-data", getTabloDataRouter(middlewares)); + mainRouter.route("/notes", getNotesRouter(middlewares)); + // stripe routes + mainRouter.route("/stripe", getStripeRouter(middlewares, config, stripe)); + mainRouter.route("/stripe-webhook", getStripeWebhookRouter(stripeSync)); -mainRouter.route("/users", userRouter); -mainRouter.route("/tablos", tabloRouter); -mainRouter.route("/tasks", taskRouter); -mainRouter.route("/tablo-data", tabloDataRouter); -mainRouter.route("/notes", notesRouter); -// stripe routes -mainRouter.route("/stripe", stripeRouter); -mainRouter.route("/stripe-webhook", stripeWebhookRouter); + return mainRouter; +}; diff --git a/api/src/secrets.ts b/api/src/secrets.ts new file mode 100644 index 0000000..f458c3d --- /dev/null +++ b/api/src/secrets.ts @@ -0,0 +1,51 @@ +import { SecretManagerServiceClient } from "@google-cloud/secret-manager"; + +const client = new SecretManagerServiceClient(); + +const SECRET_PREFIX = "projects/xtablo/secrets/"; +const SECRET_SUFFIX = "/versions/latest"; + +/** + * fetchSecret retrieves the latest version of the secret from secret manager. + * @param {string} tokenName The name of the secret in Secret Manager + * @return {string} The sensitive value stored in Secret Manager. + */ +async function fetchSecret(tokenName) { + const [version] = await client.accessSecretVersion({ + name: SECRET_PREFIX + tokenName + SECRET_SUFFIX, + }); + return version.payload.data.toString(); +} + +export type Secrets = { + supabaseServiceRoleKey: string; + supabaseConnectionString: string; + supabaseCaCert: string; + streamChatApiSecret: string; + stripeSecretKey: string; + stripeWebhookSecret: string; + emailClientSecret: string; + emailRefreshToken: string; + r2AccessKeyId: string; + r2SecretAccessKey: string; +}; + +/** + * loadSecrets retrieves all the secrets needed for the program + * @return {object} The object with all of the secrets + */ +export async function loadSecrets(): Promise { + const secrets = { + supabaseServiceRoleKey: await fetchSecret("supabase-service-role-key"), + supabaseConnectionString: await fetchSecret("supabase-connection-string"), + supabaseCaCert: await fetchSecret("supabase-ca-cert"), + streamChatApiSecret: await fetchSecret("stream-chat-api-secret"), + stripeSecretKey: await fetchSecret("stripe-secret-key"), + stripeWebhookSecret: await fetchSecret("stripe-webhook-secret"), + emailClientSecret: await fetchSecret("email-client-secret"), + emailRefreshToken: await fetchSecret("email-refresh-token"), + r2AccessKeyId: await fetchSecret("r2-access-key-id"), + r2SecretAccessKey: await fetchSecret("r2-secret-access-key"), + }; + return secrets; +} diff --git a/api/src/stripe.ts b/api/src/stripe.ts index 4edf93c..5a6134b 100644 --- a/api/src/stripe.ts +++ b/api/src/stripe.ts @@ -1,279 +1,287 @@ -import { Hono } from "hono"; +import type { StripeSync } from "@supabase/stripe-sync-engine"; import type { SupabaseClient, User } from "@supabase/supabase-js"; +import { Hono } from "hono"; import Stripe from "stripe"; -import { authMiddleware, regularUserCheckMiddleware } from "./middleware.js"; -import { config } from "./config.js"; -import { stripeSync } from "./stripeSync.js"; +import type { AppConfig } from "./config.js"; +import type { Middlewares } from "./middleware.js"; -const stripe = new Stripe(config.STRIPE_SECRET_KEY || "", { - apiVersion: "2025-10-29.clover", -}); +export const getStripeWebhookRouter = (stripeSync: StripeSync) => { + const stripeWebhookRouter = new Hono(); -export const stripeRouter = new Hono<{ - Variables: { - user: User; - supabase: SupabaseClient; - }; -}>(); + /** + * Stripe webhook handler using @supabase/stripe-sync-engine + * This automatically syncs all Stripe events to Supabase tables + * Repository: https://github.com/supabase/stripe-sync-engine + */ + stripeWebhookRouter.post("/", async (c) => { + try { + const signature = c.req.header("stripe-signature"); -stripeRouter.use(authMiddleware); + if (!signature) { + return c.json({ error: "No signature provided" }, 400); + } -// ============================================================================ -// Webhook endpoint (no auth required - validated by signature) -// ============================================================================ + // Get raw body for signature verification + const rawBody = await c.req.text(); -export const stripeWebhookRouter = new Hono(); + // Process webhook using Stripe Sync Engine + // This handles signature verification and syncing automatically + await stripeSync.processWebhook(rawBody, signature); -/** - * Stripe webhook handler using @supabase/stripe-sync-engine - * This automatically syncs all Stripe events to Supabase tables - * Repository: https://github.com/supabase/stripe-sync-engine - */ -stripeWebhookRouter.post("/", async (c) => { - try { - const signature = c.req.header("stripe-signature"); - - if (!signature) { - return c.json({ error: "No signature provided" }, 400); + return c.json({ received: true }); + } catch (error) { + console.error("Webhook error:", error); + return c.json( + { error: error instanceof Error ? error.message : "Webhook processing failed" }, + 400 + ); } + }); - // Get raw body for signature verification - const rawBody = await c.req.text(); + return stripeWebhookRouter; +}; - // Process webhook using Stripe Sync Engine - // This handles signature verification and syncing automatically - await stripeSync.processWebhook(rawBody, signature); +export const getStripeRouter = (middlewares: Middlewares, config: AppConfig, stripe: Stripe) => { + const stripeRouter = new Hono<{ + Variables: { + user: User; + supabase: SupabaseClient; + }; + }>(); - return c.json({ received: true }); - } catch (error) { - console.error("Webhook error:", error); - return c.json( - { error: error instanceof Error ? error.message : "Webhook processing failed" }, - 400 - ); - } -}); + stripeRouter.use(middlewares.authMiddleware); -// ============================================================================ -// Authenticated endpoints -// ============================================================================ + // ============================================================================ + // Authenticated endpoints + // ============================================================================ -/** - * Create a Stripe Checkout Session - * POST /api/v1/stripe/create-checkout-session - */ -stripeRouter.post("/create-checkout-session", regularUserCheckMiddleware, async (c) => { - const user = c.get("user"); - const supabase = c.get("supabase"); - const body = await c.req.json(); - const { priceId, successUrl, cancelUrl } = body; + /** + * Create a Stripe Checkout Session + * POST /api/v1/stripe/create-checkout-session + */ + stripeRouter.post( + "/create-checkout-session", + middlewares.regularUserCheckMiddleware, + async (c) => { + const user = c.get("user"); + const supabase = c.get("supabase"); + const body = await c.req.json(); + const { priceId, successUrl, cancelUrl } = body; - if (!priceId) { - return c.json({ error: "priceId is required" }, 400); - } + if (!priceId) { + return c.json({ error: "priceId is required" }, 400); + } - try { - // Get or create Stripe customer - let customerId: string; + try { + // Get or create Stripe customer + let customerId: string; - // Check if customer already exists by querying stripe schema with metadata filter - // Note: Using service role, so we filter manually by metadata - const { data: customers } = await supabase - .schema("stripe") - .from("customers") - .select("id, metadata") - .limit(1000); // Get all customers to filter by metadata + // Check if customer already exists by querying stripe schema with metadata filter + // Note: Using service role, so we filter manually by metadata + const { data: customers } = await supabase + .schema("stripe") + .from("customers") + .select("id, metadata") + .limit(1000); // Get all customers to filter by metadata - const existingCustomer = customers?.find( - (c: Stripe.Customer) => c.metadata?.user_id === user.id - ); + const existingCustomer = customers?.find( + (c: Stripe.Customer) => c.metadata?.user_id === user.id + ); - if (existingCustomer) { - customerId = existingCustomer.id; - } else { - // Create new Stripe customer with user_id in metadata - // stripe-sync-engine will automatically sync this to the database via webhook - const customer = await stripe.customers.create({ - email: user.email!, - metadata: { - user_id: user.id, // Stored in metadata for tracking - }, + if (existingCustomer) { + customerId = existingCustomer.id; + } else { + // Create new Stripe customer with user_id in metadata + // stripe-sync-engine will automatically sync this to the database via webhook + const customer = await stripe.customers.create({ + email: user.email!, + metadata: { + user_id: user.id, // Stored in metadata for tracking + }, + }); + + customerId = customer.id; + } + + // Create Checkout Session + const session = await stripe.checkout.sessions.create({ + customer: customerId, + line_items: [ + { + price: priceId, + quantity: 1, + }, + ], + mode: "subscription", + success_url: successUrl || `${config.XTABLO_URL}/settings?success=true`, + cancel_url: cancelUrl || `${config.XTABLO_URL}/settings?canceled=true`, + metadata: { + user_id: user.id, + }, + subscription_data: { + metadata: { + user_id: user.id, + }, + }, + }); + + return c.json({ sessionId: session.id, url: session.url }); + } catch (error) { + console.error("Error creating checkout session:", error); + return c.json( + { error: error instanceof Error ? error.message : "Failed to create checkout session" }, + 500 + ); + } + } + ); + + /** + * Create a Stripe Customer Portal Session + * POST /api/v1/stripe/create-portal-session + */ + stripeRouter.post("/create-portal-session", middlewares.regularUserCheckMiddleware, async (c) => { + const user = c.get("user"); + const supabase = c.get("supabase"); + const body = await c.req.json(); + const { returnUrl } = body; + + try { + // Get Stripe customer ID by filtering metadata + const { data: customers } = await supabase + .schema("stripe") + .from("customers") + .select("id, metadata"); + + const customer = customers?.find((c: Stripe.Customer) => c.metadata?.user_id === user.id); + + if (!customer) { + return c.json({ error: "No Stripe customer found" }, 404); + } + + // Create portal session + const session = await stripe.billingPortal.sessions.create({ + customer: customer.id, + return_url: returnUrl || `${config.XTABLO_URL}/settings`, }); - customerId = customer.id; + return c.json({ url: session.url }); + } catch (error) { + console.error("Error creating portal session:", error); + return c.json( + { error: error instanceof Error ? error.message : "Failed to create portal session" }, + 500 + ); } + }); - // Create Checkout Session - const session = await stripe.checkout.sessions.create({ - customer: customerId, - line_items: [ - { - price: priceId, - quantity: 1, - }, - ], - mode: "subscription", - success_url: successUrl || `${process.env.FRONTEND_URL}/settings?success=true`, - cancel_url: cancelUrl || `${process.env.FRONTEND_URL}/settings?canceled=true`, - metadata: { - user_id: user.id, - }, - subscription_data: { - metadata: { - user_id: user.id, - }, - }, - }); + // Note: Subscription status queries are handled directly from the frontend + // using Supabase client with RLS policies. No API endpoints needed for reads. - return c.json({ sessionId: session.id, url: session.url }); - } catch (error) { - console.error("Error creating checkout session:", error); - return c.json( - { error: error instanceof Error ? error.message : "Failed to create checkout session" }, - 500 - ); - } -}); + /** + * Cancel subscription at period end + * POST /api/v1/stripe/cancel-subscription + */ + stripeRouter.post("/cancel-subscription", middlewares.regularUserCheckMiddleware, async (c) => { + const user = c.get("user"); + const supabase = c.get("supabase"); -/** - * Create a Stripe Customer Portal Session - * POST /api/v1/stripe/create-portal-session - */ -stripeRouter.post("/create-portal-session", regularUserCheckMiddleware, async (c) => { - const user = c.get("user"); - const supabase = c.get("supabase"); - const body = await c.req.json(); - const { returnUrl } = body; + try { + // Get user's Stripe customer first + const { data: customers } = await supabase + .schema("stripe") + .from("customers") + .select("id, metadata"); - try { - // Get Stripe customer ID by filtering metadata - const { data: customers } = await supabase - .schema("stripe") - .from("customers") - .select("id, metadata"); + const customer = customers?.find((c: Stripe.Customer) => c.metadata?.user_id === user.id); - const customer = customers?.find((c: Stripe.Customer) => c.metadata?.user_id === user.id); + if (!customer) { + return c.json({ error: "Customer not found" }, 404); + } - if (!customer) { - return c.json({ error: "No Stripe customer found" }, 404); + // Get user's active subscription for this customer + const { data: subscription } = await supabase + .schema("stripe") + .from("subscriptions") + .select("id, status") + .eq("customer", customer.id) + .in("status", ["active", "trialing"]) + .maybeSingle(); + + if (!subscription) { + return c.json({ error: "No active subscription found" }, 404); + } + + // Cancel subscription at period end in Stripe + // The webhook will automatically sync the change to our database + await stripe.subscriptions.cancel(subscription.id); + + return c.json({ + success: true, + message: "Subscription will cancel at period end", + }); + } catch (error) { + console.error("Error canceling subscription:", error); + return c.json( + { error: error instanceof Error ? error.message : "Failed to cancel subscription" }, + 500 + ); } + }); - // Create portal session - const session = await stripe.billingPortal.sessions.create({ - customer: customer.id, - return_url: returnUrl || `${process.env.FRONTEND_URL}/settings`, - }); + /** + * Reactivate a canceled subscription + * POST /api/v1/stripe/reactivate-subscription + */ + stripeRouter.post( + "/reactivate-subscription", + middlewares.regularUserCheckMiddleware, + async (c) => { + const user = c.get("user"); + const supabase = c.get("supabase"); - return c.json({ url: session.url }); - } catch (error) { - console.error("Error creating portal session:", error); - return c.json( - { error: error instanceof Error ? error.message : "Failed to create portal session" }, - 500 - ); - } -}); + try { + // Get user's Stripe customer first + const { data: customers } = await supabase + .schema("stripe") + .from("customers") + .select("id, metadata"); -// Note: Subscription status queries are handled directly from the frontend -// using Supabase client with RLS policies. No API endpoints needed for reads. + const customer = customers?.find((c: Stripe.Customer) => c.metadata?.user_id === user.id); -/** - * Cancel subscription at period end - * POST /api/v1/stripe/cancel-subscription - */ -stripeRouter.post("/cancel-subscription", regularUserCheckMiddleware, async (c) => { - const user = c.get("user"); - const supabase = c.get("supabase"); + if (!customer) { + return c.json({ error: "No subscription found to reactivate" }, 404); + } - try { - // Get user's Stripe customer first - const { data: customers } = await supabase - .schema("stripe") - .from("customers") - .select("id, metadata"); + // Get user's subscription that's set to cancel + const { data: subscription } = await supabase + .schema("stripe") + .from("subscriptions") + .select("id, cancel_at_period_end") + .eq("customer", customer.id) + .eq("cancel_at_period_end", true) + .maybeSingle(); - const customer = customers?.find((c: Stripe.Customer) => c.metadata?.user_id === user.id); + if (!subscription) { + return c.json({ error: "No subscription found to reactivate" }, 404); + } - if (!customer) { - return c.json({ error: "Customer not found" }, 404); + // Reactivate subscription in Stripe + // The webhook will automatically sync the change to our database + await stripe.subscriptions.update(subscription.id, { + cancel_at_period_end: false, + }); + + return c.json({ success: true, message: "Subscription reactivated" }); + } catch (error) { + console.error("Error reactivating subscription:", error); + return c.json( + { error: error instanceof Error ? error.message : "Failed to reactivate subscription" }, + 500 + ); + } } + ); - // Get user's active subscription for this customer - const { data: subscription } = await supabase - .schema("stripe") - .from("subscriptions") - .select("id, status") - .eq("customer", customer.id) - .in("status", ["active", "trialing"]) - .maybeSingle(); - - if (!subscription) { - return c.json({ error: "No active subscription found" }, 404); - } - - // Cancel subscription at period end in Stripe - // The webhook will automatically sync the change to our database - await stripe.subscriptions.cancel(subscription.id); - - return c.json({ - success: true, - message: "Subscription will cancel at period end", - }); - } catch (error) { - console.error("Error canceling subscription:", error); - return c.json( - { error: error instanceof Error ? error.message : "Failed to cancel subscription" }, - 500 - ); - } -}); - -/** - * Reactivate a canceled subscription - * POST /api/v1/stripe/reactivate-subscription - */ -stripeRouter.post("/reactivate-subscription", regularUserCheckMiddleware, async (c) => { - const user = c.get("user"); - const supabase = c.get("supabase"); - - try { - // Get user's Stripe customer first - const { data: customers } = await supabase - .schema("stripe") - .from("customers") - .select("id, metadata"); - - const customer = customers?.find((c: Stripe.Customer) => c.metadata?.user_id === user.id); - - if (!customer) { - return c.json({ error: "No subscription found to reactivate" }, 404); - } - - // Get user's subscription that's set to cancel - const { data: subscription } = await supabase - .schema("stripe") - .from("subscriptions") - .select("id, cancel_at_period_end") - .eq("customer", customer.id) - .eq("cancel_at_period_end", true) - .maybeSingle(); - - if (!subscription) { - return c.json({ error: "No subscription found to reactivate" }, 404); - } - - // Reactivate subscription in Stripe - // The webhook will automatically sync the change to our database - await stripe.subscriptions.update(subscription.id, { - cancel_at_period_end: false, - }); - - return c.json({ success: true, message: "Subscription reactivated" }); - } catch (error) { - console.error("Error reactivating subscription:", error); - return c.json( - { error: error instanceof Error ? error.message : "Failed to reactivate subscription" }, - 500 - ); - } -}); + return stripeRouter; +}; diff --git a/api/src/stripeSync.ts b/api/src/stripeSync.ts index ee7d429..1f1df40 100644 --- a/api/src/stripeSync.ts +++ b/api/src/stripeSync.ts @@ -1,19 +1,21 @@ import { StripeSync } from "@supabase/stripe-sync-engine"; -import { config } from "./config.js"; +import type { AppConfig } from "./config.js"; -const ssl = { - ca: Buffer.from(config.SUPABASE_CA_CERT, "base64").toString("utf-8"), +export const createStripeSync = (config: AppConfig): StripeSync => { + const ssl = { + ca: Buffer.from(config.SUPABASE_CA_CERT, "base64").toString("utf-8"), + }; + + return new StripeSync({ + stripeSecretKey: config.STRIPE_SECRET_KEY || "", + stripeWebhookSecret: config.STRIPE_WEBHOOK_SECRET || "", + schema: "stripe", // Use stripe schema (library default) + poolConfig: { + connectionString: config.SUPABASE_CONNECTION_STRING || "", // Direct Postgres connection string + ssl, + max: 10, + }, + // Optional: force refetch from Stripe API to avoid stale data + revalidateObjectsViaStripeApi: ["subscription", "customer"], + }); }; - -export const stripeSync = new StripeSync({ - stripeSecretKey: config.STRIPE_SECRET_KEY || "", - stripeWebhookSecret: config.STRIPE_WEBHOOK_SECRET || "", - schema: "stripe", // Use stripe schema (library default) - poolConfig: { - connectionString: config.SUPABASE_CONNECTION_STRING || "", // Direct Postgres connection string - ssl, - max: 10, - }, - // Optional: force refetch from Stripe API to avoid stale data - revalidateObjectsViaStripeApi: ["subscription", "customer"], -}); diff --git a/api/src/tablo.ts b/api/src/tablo.ts index ad38b3d..ac4e75e 100644 --- a/api/src/tablo.ts +++ b/api/src/tablo.ts @@ -3,182 +3,60 @@ import { type SupabaseClient, type User } from "@supabase/supabase-js"; import { Hono } from "hono"; import type { Transporter } from "nodemailer"; import type { StreamChat } from "stream-chat"; -import { config } from "./config.js"; +import type { AppConfig } from "./config.js"; import type { Tables } from "./database.types.ts"; import { checkTabloAdmin, writeCalendarFileToR2 } from "./helpers.js"; -import { - authMiddleware, - r2Middleware, - regularUserCheckMiddleware, - streamChatMiddleware, -} from "./middleware.js"; +import type { Middlewares } from "./middleware.js"; import { generatePassword, generateToken } from "./token.js"; -import { transporter } from "./transporter.js"; import type { EventInsertInTablo, TabloInsert } from "./types.ts"; -export const tabloRouter = new Hono<{ - Variables: { - user: User; - supabase: SupabaseClient; - transporter: Transporter; - streamServerClient: StreamChat; - s3_client: S3Client; - }; -}>(); +export const getTabloRouter = ( + middlewares: Middlewares, + config: AppConfig, + transporter: Transporter +) => { + const tabloRouter = new Hono<{ + Variables: { + user: User; + supabase: SupabaseClient; + streamServerClient: StreamChat; + s3_client: S3Client; + }; + }>(); -// const webcalRouter = new Hono<{ -// Variables: { -// user: User; -// supabase: SupabaseClient; -// s3_client: S3Client; -// }; -// }>(); + // const webcalRouter = new Hono<{ + // Variables: { + // user: User; + // supabase: SupabaseClient; + // s3_client: S3Client; + // }; + // }>(); -// webcalRouter.use(r2Middleware); + // webcalRouter.use(r2Middleware); -tabloRouter.use(authMiddleware); -tabloRouter.use(streamChatMiddleware); -tabloRouter.use(r2Middleware); + tabloRouter.use(middlewares.authMiddleware); + tabloRouter.use(middlewares.streamChatMiddleware); + tabloRouter.use(middlewares.r2Middleware); -// tabloRouter.route("/webcal", webcalRouter); + // tabloRouter.route("/webcal", webcalRouter); -type PostTablo = Omit & { - events?: EventInsertInTablo[]; -}; - -tabloRouter.post("/create", regularUserCheckMiddleware, async (c) => { - const user = c.get("user"); - const supabase = c.get("supabase"); - const data = await c.req.json(); - - const typedPayload = data as PostTablo; - - const { data: insertedTablo, error } = await supabase - .from("tablos") - .insert({ - ...typedPayload, - owner_id: user.id, - events: undefined, - }) - .select() - .single(); - - if (error) { - return c.json({ error: error.message }, 500); - } - - const tabloData = insertedTablo as Tables<"tablos">; - - const streamServerClient = c.get("streamServerClient"); - const channel = streamServerClient.channel("messaging", tabloData.id, { - // @ts-ignore - name: tabloData.name, - created_by_id: user.id, - members: [user.id], - }); - await channel.create(); - - if (typedPayload.events) { - const eventsToInsert = typedPayload.events.map((event) => ({ - ...event, - tablo_id: tabloData.id, - created_by: user.id, - })); - - await supabase.from("events").insert(eventsToInsert); - } - return c.json({ message: "Tablo created successfully" }); -}); - -type PostTabloWithOwner = Omit & { - event: EventInsertInTablo; - owner_short_id: string; -}; - -tabloRouter.post("/create-and-invite", async (c) => { - const user = c.get("user"); - const supabase = c.get("supabase"); - const streamServerClient = c.get("streamServerClient"); - const data = await c.req.json(); - - const typedPayload = data as PostTabloWithOwner; - - // Validate that owner_id is provided - if (!typedPayload.owner_short_id) { - return c.json({ error: "owner_id is required" }, 400); - } - - if (!typedPayload.event) { - return c.json({ error: "event is required" }, 400); - } - - // TODO: Verify that the owner_id is correct - const { data: ownerData, error: ownerError } = await supabase - .from("profiles") - .select("id, name, email") - .eq("short_user_id", typedPayload.owner_short_id) - .single(); - - const { data: invitedUser, error: invitedUserError } = await supabase - .from("profiles") - .select("id, name, email") - .eq("id", user.id) - .single(); - - if (ownerError || !ownerData || invitedUserError || !invitedUser) { - return c.json({ error: "owner_id or invited_user_id is incorrect" }, 400); - } - - const ownerDataTyped = ownerData as { - id: string; - name: string; - email: string; - }; - const ownerId = ownerDataTyped.id; - const invitedUserDataTyped = invitedUser as { - id: string; - name: string; - email: string; + type PostTablo = Omit & { + events?: EventInsertInTablo[]; }; - if (ownerId === user.id) { - return c.json({ error: "You cannot create a tablo with yourself" }, 400); - } + tabloRouter.post("/create", middlewares.regularUserCheckMiddleware, async (c) => { + const user = c.get("user"); + const supabase = c.get("supabase"); + const data = await c.req.json(); - // TODO: Verify that the event start and end correspond to a slot + const typedPayload = data as PostTablo; - // Check if there's already a tablo between the owner and the invited user - const { data: existingTablo, error: existingTabloError } = await supabase - .from("tablos") - .select( - ` - id, - name, - owner_id, - tablo_access!inner(user_id) - ` - ) - .eq("owner_id", ownerId) - .eq("tablo_access.user_id", user.id) - .is("deleted_at", null) - .limit(1); - - if (existingTabloError) { - console.error("existingTabloError", existingTabloError); - return c.json({ error: existingTabloError.message }, 500); - } - - let tabloData: { id: string; name: string } | null = null; - - if (!existingTablo.length) { - // Create the tablo with the specified owner const { data: insertedTablo, error } = await supabase .from("tablos") .insert({ - name: `${invitedUserDataTyped.name || "Invité"} / ${ownerDataTyped.name || "Propriétaire"}`, - color: "bg-blue-500", - status: "todo", - owner_id: ownerId, + ...typedPayload, + owner_id: user.id, + events: undefined, }) .select() .single(); @@ -187,60 +65,180 @@ tabloRouter.post("/create-and-invite", async (c) => { return c.json({ error: error.message }, 500); } - tabloData = insertedTablo as { id: string; name: string }; - } else { - tabloData = existingTablo[0] as { id: string; name: string }; - } + const tabloData = insertedTablo as Tables<"tablos">; - // Grant access to the current user (invited user) as a non-admin member - const { error: tabloAccessError } = await supabase.from("tablo_access").insert( - { - tablo_id: tabloData.id, - user_id: user.id, - // ** IMPORTANT ** - is_admin: false, - // ------------- - is_active: true, - granted_by: ownerId, + const streamServerClient = c.get("streamServerClient"); + const channel = streamServerClient.channel("messaging", tabloData.id, { + // @ts-ignore + name: tabloData.name, + created_by_id: user.id, + members: [user.id], + }); + await channel.create(); + + if (typedPayload.events) { + const eventsToInsert = typedPayload.events.map((event) => ({ + ...event, + tablo_id: tabloData.id, + created_by: user.id, + })); + + await supabase.from("events").insert(eventsToInsert); } - // { - // onConflict: "tablo_id, user_id", - // } - ); - - if (tabloAccessError) { - console.error("tabloAccessError", tabloAccessError); - return c.json({ error: tabloAccessError.message }, 500); - } - - // Create Stream chat channel with the owner as creator - const channel = streamServerClient.channel("messaging", tabloData.id, { - // @ts-ignore - name: tabloData.name, - created_by_id: ownerId, - members: [ownerId, user.id], - }); - await channel.create(); - - // Send a welcome message to the channel - await channel.sendMessage({ - text: `🎉 Bienvenue dans votre nouveau tablo "${tabloData.name}" ! Votre rendez-vous "${typedPayload.event.title}" est confirmé pour le ${typedPayload.event.start_date} de ${typedPayload.event.start_time} à ${typedPayload.event.end_time}.`, - user_id: ownerId, + return c.json({ message: "Tablo created successfully" }); }); - await supabase.from("events").insert({ - ...typedPayload.event, - tablo_id: tabloData.id, - created_by: ownerId, - }); + type PostTabloWithOwner = Omit & { + event: EventInsertInTablo; + owner_short_id: string; + }; - // Send email notifications to both owner and invited user - // Send email to the owner - await transporter.sendMail({ - from: "Xtablo ", - to: ownerDataTyped.email, - subject: "Nouveau tablo créé - Réservation confirmée", - html: ` + tabloRouter.post("/create-and-invite", async (c) => { + const user = c.get("user"); + const supabase = c.get("supabase"); + const streamServerClient = c.get("streamServerClient"); + const data = await c.req.json(); + + const typedPayload = data as PostTabloWithOwner; + + // Validate that owner_id is provided + if (!typedPayload.owner_short_id) { + return c.json({ error: "owner_id is required" }, 400); + } + + if (!typedPayload.event) { + return c.json({ error: "event is required" }, 400); + } + + // TODO: Verify that the owner_id is correct + const { data: ownerData, error: ownerError } = await supabase + .from("profiles") + .select("id, name, email") + .eq("short_user_id", typedPayload.owner_short_id) + .single(); + + const { data: invitedUser, error: invitedUserError } = await supabase + .from("profiles") + .select("id, name, email") + .eq("id", user.id) + .single(); + + if (ownerError || !ownerData || invitedUserError || !invitedUser) { + return c.json({ error: "owner_id or invited_user_id is incorrect" }, 400); + } + + const ownerDataTyped = ownerData as { + id: string; + name: string; + email: string; + }; + const ownerId = ownerDataTyped.id; + const invitedUserDataTyped = invitedUser as { + id: string; + name: string; + email: string; + }; + + if (ownerId === user.id) { + return c.json({ error: "You cannot create a tablo with yourself" }, 400); + } + + // TODO: Verify that the event start and end correspond to a slot + + // Check if there's already a tablo between the owner and the invited user + const { data: existingTablo, error: existingTabloError } = await supabase + .from("tablos") + .select( + ` + id, + name, + owner_id, + tablo_access!inner(user_id) + ` + ) + .eq("owner_id", ownerId) + .eq("tablo_access.user_id", user.id) + .is("deleted_at", null) + .limit(1); + + if (existingTabloError) { + console.error("existingTabloError", existingTabloError); + return c.json({ error: existingTabloError.message }, 500); + } + + let tabloData: { id: string; name: string } | null = null; + + if (!existingTablo.length) { + // Create the tablo with the specified owner + const { data: insertedTablo, error } = await supabase + .from("tablos") + .insert({ + name: `${invitedUserDataTyped.name || "Invité"} / ${ownerDataTyped.name || "Propriétaire"}`, + color: "bg-blue-500", + status: "todo", + owner_id: ownerId, + }) + .select() + .single(); + + if (error) { + return c.json({ error: error.message }, 500); + } + + tabloData = insertedTablo as { id: string; name: string }; + } else { + tabloData = existingTablo[0] as { id: string; name: string }; + } + + // Grant access to the current user (invited user) as a non-admin member + const { error: tabloAccessError } = await supabase.from("tablo_access").insert( + { + tablo_id: tabloData.id, + user_id: user.id, + // ** IMPORTANT ** + is_admin: false, + // ------------- + is_active: true, + granted_by: ownerId, + } + // { + // onConflict: "tablo_id, user_id", + // } + ); + + if (tabloAccessError) { + console.error("tabloAccessError", tabloAccessError); + return c.json({ error: tabloAccessError.message }, 500); + } + + // Create Stream chat channel with the owner as creator + const channel = streamServerClient.channel("messaging", tabloData.id, { + // @ts-ignore + name: tabloData.name, + created_by_id: ownerId, + members: [ownerId, user.id], + }); + await channel.create(); + + // Send a welcome message to the channel + await channel.sendMessage({ + text: `🎉 Bienvenue dans votre nouveau tablo "${tabloData.name}" ! Votre rendez-vous "${typedPayload.event.title}" est confirmé pour le ${typedPayload.event.start_date} de ${typedPayload.event.start_time} à ${typedPayload.event.end_time}.`, + user_id: ownerId, + }); + + await supabase.from("events").insert({ + ...typedPayload.event, + tablo_id: tabloData.id, + created_by: ownerId, + }); + + // Send email notifications to both owner and invited user + // Send email to the owner + await transporter.sendMail({ + from: "Xtablo ", + to: ownerDataTyped.email, + subject: "Nouveau tablo créé - Réservation confirmée", + html: `

Votre tablo a été créé avec succès !

Bonjour ${ownerDataTyped.name},

Un nouveau tablo "${tabloData.name}" a été créé suite à une réservation.

@@ -254,14 +252,14 @@ tabloRouter.post("/create-and-invite", async (c) => {

Participant : ${invitedUserDataTyped.name} (${invitedUserDataTyped.email})

Vous pouvez gérer ce tablo depuis votre tableau de bord.

`, - }); + }); - // Send email to the invited user - await transporter.sendMail({ - from: "Xtablo ", - to: invitedUserDataTyped.email, - subject: "Réservation confirmée - Nouveau tablo créé", - html: ` + // Send email to the invited user + await transporter.sendMail({ + from: "Xtablo ", + to: invitedUserDataTyped.email, + subject: "Réservation confirmée - Nouveau tablo créé", + html: `

Votre réservation est confirmée !

Bonjour ${invitedUserDataTyped.name},

Votre réservation a été confirmée et un tablo "${tabloData.name}" a été créé.

@@ -275,198 +273,202 @@ tabloRouter.post("/create-and-invite", async (c) => {

Avec : ${ownerDataTyped.name}

Vous recevrez bientôt plus d'informations pour accéder à votre espace de collaboration.

`, + }); + + return c.json({ id: tabloData.id }); }); - return c.json({ id: tabloData.id }); -}); + tabloRouter.patch("/update", middlewares.regularUserCheckMiddleware, async (c) => { + const user = c.get("user"); + const supabase = c.get("supabase"); + const streamServerClient = c.get("streamServerClient"); + const data = await c.req.json(); -tabloRouter.patch("/update", regularUserCheckMiddleware, async (c) => { - const user = c.get("user"); - const supabase = c.get("supabase"); - const streamServerClient = c.get("streamServerClient"); - const data = await c.req.json(); + const { id, ...tablo } = data; - const { id, ...tablo } = data; + const { data: update, error } = await supabase + .from("tablos") + .update(tablo) + .eq("id", id) + // TODO: this condition will need to be modified in the future + .eq("owner_id", user.id) + .select() + .single(); - const { data: update, error } = await supabase - .from("tablos") - .update(tablo) - .eq("id", id) - // TODO: this condition will need to be modified in the future - .eq("owner_id", user.id) - .select() - .single(); + const updatedTablo = update as Tables<"tablos">; - const updatedTablo = update as Tables<"tablos">; + const isUpdatingName = tablo.name !== undefined && tablo.name !== updatedTablo.name; - const isUpdatingName = tablo.name !== undefined && tablo.name !== updatedTablo.name; + if (error) { + return c.json({ error: error.message }, 500); + } - if (error) { - return c.json({ error: error.message }, 500); - } + if (isUpdatingName) { + const channel = streamServerClient.channel("messaging", updatedTablo.id); + try { + await channel.update({ + // @ts-ignore + name: updatedTablo.name, + }); + } catch (error) { + console.error("error updating channel", error); + } + } - if (isUpdatingName) { - const channel = streamServerClient.channel("messaging", updatedTablo.id); + return c.json({ message: "Tablo updated successfully" }); + }); + + tabloRouter.delete("/delete", async (c) => { + const user = c.get("user"); + const supabase = c.get("supabase"); + const streamServerClient = c.get("streamServerClient"); + const data = await c.req.json(); + + const { id } = data; + + const { error } = await supabase + .from("tablos") + .update({ deleted_at: new Date().toISOString() }) + .eq("id", id) + .eq("owner_id", user.id); + + // Verify that the user has admin access to this tablo + const { data: tabloAccess, error: accessError } = await supabase + .from("tablo_access") + .select("is_admin") + .eq("tablo_id", id) + .eq("user_id", user.id) + .eq("is_active", true) + .single(); + + if (accessError || !tabloAccess || !tabloAccess.is_admin) { + return c.json({ error: "You are not authorized to delete this tablo" }, 403); + } + + if (error) { + return c.json({ error: error.message }, 500); + } + + const channel = streamServerClient.channel("messaging", id); try { - await channel.update({ - // @ts-ignore - name: updatedTablo.name, - }); + await channel.delete(); } catch (error) { - console.error("error updating channel", error); + console.error("error deleting channel", error); } - } - return c.json({ message: "Tablo updated successfully" }); -}); - -tabloRouter.delete("/delete", async (c) => { - const user = c.get("user"); - const supabase = c.get("supabase"); - const streamServerClient = c.get("streamServerClient"); - const data = await c.req.json(); - - const { id } = data; - - const { error } = await supabase - .from("tablos") - .update({ deleted_at: new Date().toISOString() }) - .eq("id", id) - .eq("owner_id", user.id); - - // Verify that the user has admin access to this tablo - const { data: tabloAccess, error: accessError } = await supabase - .from("tablo_access") - .select("is_admin") - .eq("tablo_id", id) - .eq("user_id", user.id) - .eq("is_active", true) - .single(); - - if (accessError || !tabloAccess || !tabloAccess.is_admin) { - return c.json({ error: "You are not authorized to delete this tablo" }, 403); - } - - if (error) { - return c.json({ error: error.message }, 500); - } - - const channel = streamServerClient.channel("messaging", id); - try { - await channel.delete(); - } catch (error) { - console.error("error deleting channel", error); - } - - return c.json({ message: "Tablo deleted successfully" }); -}); - -tabloRouter.post("/invite/:tabloId", regularUserCheckMiddleware, checkTabloAdmin, async (c) => { - const sender = c.get("user"); - const supabase = c.get("supabase"); - const { tabloId } = c.req.param(); - const { email: recipientmail } = await c.req.json(); - - if (sender.email === recipientmail) { - return c.json({ error: "You cannot invite yourself" }, 400); - } - - // Get tablo name - const { data: tablo, error: tabloError } = await supabase - .from("tablos") - .select("name") - .eq("id", tabloId) - .maybeSingle(); - - if (tabloError || !tablo) { - return c.json({ error: "Tablo not found" }, 404); - } - - const token = generateToken(); - - const { data: introConfigData, error: introError } = await supabase - .from("user_introductions") - .select("config") - .eq("user_id", sender.id) - .maybeSingle(); - - if (introError) { - return c.json({ error: introError.message }, 500); - } - const introEmail = introConfigData?.config?.intro_email; - - const { error } = await supabase.from("tablo_invites").insert({ - invited_email: recipientmail, - tablo_id: tabloId, - invited_by: sender.id, - invite_token: token, - is_pending: true, + return c.json({ message: "Tablo deleted successfully" }); }); - if (error) { - // Check if this is a duplicate invite error - if (error.code === "23505") { - return c.json({ error: "User has already been invited to this tablo" }, 409); - } - return c.json({ error: error.message }, 500); - } + tabloRouter.post( + "/invite/:tabloId", + middlewares.regularUserCheckMiddleware, + checkTabloAdmin, + async (c) => { + const sender = c.get("user"); + const supabase = c.get("supabase"); + const { tabloId } = c.req.param(); + const { email: recipientmail } = await c.req.json(); - // Get user from recipient email - const { data: recipientUser, error: recipientError } = await supabase - .from("profiles") - .select("id") - .eq("email", recipientmail) - .maybeSingle(); + if (sender.email === recipientmail) { + return c.json({ error: "You cannot invite yourself" }, 400); + } - if (recipientError) { - return c.json({ error: recipientError.message }, 500); - } + // Get tablo name + const { data: tablo, error: tabloError } = await supabase + .from("tablos") + .select("name") + .eq("id", tabloId) + .maybeSingle(); - if (!recipientUser) { - // Create a new invited user and add them to the tablo - // Create a new user account for the invited email - const password = generatePassword(); - const { data: newUser, error: createUserError } = await supabase.auth.admin.createUser({ - email: recipientmail, - password: password, - email_confirm: true, - user_metadata: { - name: recipientmail.split("@")[0], - first_name: recipientmail, - last_name: "", - role: "invited_user", - }, - app_metadata: { - // Can't do that: https://github.com/supabase/auth/issues/1280 - // role: "invited_user", - }, - }); + if (tabloError || !tablo) { + return c.json({ error: "Tablo not found" }, 404); + } - if (createUserError) { - return c.json({ error: createUserError.message }, 500); - } + const token = generateToken(); - // Add the new user to the tablo - const { error: accessError } = await supabase.from("tablo_access").insert({ - tablo_id: tabloId, - user_id: newUser.user.id, - granted_by: sender.id, - is_active: true, - // ** IMPORTANT ** - is_admin: false, - // ------------- - }); + const { data: introConfigData, error: introError } = await supabase + .from("user_introductions") + .select("config") + .eq("user_id", sender.id) + .maybeSingle(); - if (accessError) { - return c.json({ error: accessError.message }, 500); - } + if (introError) { + return c.json({ error: introError.message }, 500); + } + const introEmail = introConfigData?.config?.intro_email; - // Send welcome email to the new user - await transporter.sendMail({ - from: `${sender.email} via XTablo `, - to: recipientmail, - subject: "Vous avez été invité sur XTablo", - html: ` + const { error } = await supabase.from("tablo_invites").insert({ + invited_email: recipientmail, + tablo_id: tabloId, + invited_by: sender.id, + invite_token: token, + is_pending: true, + }); + + if (error) { + // Check if this is a duplicate invite error + if (error.code === "23505") { + return c.json({ error: "User has already been invited to this tablo" }, 409); + } + return c.json({ error: error.message }, 500); + } + + // Get user from recipient email + const { data: recipientUser, error: recipientError } = await supabase + .from("profiles") + .select("id") + .eq("email", recipientmail) + .maybeSingle(); + + if (recipientError) { + return c.json({ error: recipientError.message }, 500); + } + + if (!recipientUser) { + // Create a new invited user and add them to the tablo + // Create a new user account for the invited email + const password = generatePassword(); + const { data: newUser, error: createUserError } = await supabase.auth.admin.createUser({ + email: recipientmail, + password: password, + email_confirm: true, + user_metadata: { + name: recipientmail.split("@")[0], + first_name: recipientmail, + last_name: "", + role: "invited_user", + }, + app_metadata: { + // Can't do that: https://github.com/supabase/auth/issues/1280 + // role: "invited_user", + }, + }); + + if (createUserError) { + return c.json({ error: createUserError.message }, 500); + } + + // Add the new user to the tablo + const { error: accessError } = await supabase.from("tablo_access").insert({ + tablo_id: tabloId, + user_id: newUser.user.id, + granted_by: sender.id, + is_active: true, + // ** IMPORTANT ** + is_admin: false, + // ------------- + }); + + if (accessError) { + return c.json({ error: accessError.message }, 500); + } + + // Send welcome email to the new user + await transporter.sendMail({ + from: `${sender.email} via XTablo `, + to: recipientmail, + subject: "Vous avez été invité sur XTablo", + html: `

Bonjour !

${sender.email} vous a invité à rejoindre XTablo.

@@ -488,267 +490,271 @@ tabloRouter.post("/invite/:tabloId", regularUserCheckMiddleware, checkTabloAdmin L'équipe XTablo

`, - }); + }); - return c.json({ - message: "User created and invite sent successfully", - }); - } + return c.json({ + message: "User created and invite sent successfully", + }); + } - // Check if the user already has access to the tablo - const { data: existingAccess, error: existingAccessError } = await supabase - .from("tablo_access") - .select("id") - .eq("tablo_id", tabloId) - .eq("user_id", recipientUser.id) - .maybeSingle(); + // Check if the user already has access to the tablo + const { data: existingAccess, error: existingAccessError } = await supabase + .from("tablo_access") + .select("id") + .eq("tablo_id", tabloId) + .eq("user_id", recipientUser.id) + .maybeSingle(); - if (existingAccessError) { - return c.json({ error: existingAccessError.message }, 500); - } + if (existingAccessError) { + return c.json({ error: existingAccessError.message }, 500); + } - if (existingAccess) { - return c.json({ message: "User already has access to this tablo" }, 400); - } + if (existingAccess) { + return c.json({ message: "User already has access to this tablo" }, 400); + } - // Let the user know that they have been invited to the tablo - await transporter.sendMail({ - from: `${sender.email} via XTablo `, - to: recipientmail, - subject: "Vous avez été invité à un tablo", - html: ` + // Let the user know that they have been invited to the tablo + await transporter.sendMail({ + from: `${sender.email} via XTablo `, + to: recipientmail, + subject: "Vous avez été invité à un tablo", + html: ` ${introEmail ? `

${introEmail}

` : ""}

Cliquez sur ce lien pour accepter l'invitation.

+ config.XTABLO_URL + }/join-tablo?tablo_name=${encodeURIComponent(tablo.name)}&token=${encodeURIComponent( + token + )}">ce lien pour accepter l'invitation.


Cordialement,
L'équipe XTablo

`, - }); + }); - return c.json({ - message: "Invite sent successfully", - }); -}); + return c.json({ + message: "Invite sent successfully", + }); + } + ); -tabloRouter.post("/join", async (c) => { - const { token } = await c.req.json(); + tabloRouter.post("/join", async (c) => { + const { token } = await c.req.json(); - const joiner = c.get("user"); - const supabase = c.get("supabase"); - const streamServerClient = c.get("streamServerClient"); + const joiner = c.get("user"); + const supabase = c.get("supabase"); + const streamServerClient = c.get("streamServerClient"); - const { data: inviteData, error } = await supabase - .from("tablo_invites") - .select("id, tablo_id, invited_by") - .eq("invite_token", token) - .eq("invited_email", joiner.email) - .eq("is_pending", true) - .maybeSingle(); + const { data: inviteData, error } = await supabase + .from("tablo_invites") + .select("id, tablo_id, invited_by") + .eq("invite_token", token) + .eq("invited_email", joiner.email) + .eq("is_pending", true) + .maybeSingle(); - if (error) { - console.error("error", error); - return c.json({ error: error.message }, 500); - } - - if (!inviteData) { - return c.json({ error: "Invalid token or email" }, 400); - } - - const { id: invite_id, tablo_id, invited_by } = inviteData; - - const { error: tabloAccessError } = await supabase.from("tablo_access").insert({ - tablo_id, - user_id: joiner.id, - // ** IMPORTANT ** - is_admin: false, - // ------------- - is_active: true, - granted_by: invited_by, - }); - - if (tabloAccessError) { - console.error("tabloAccessError", tabloAccessError); - - // Check if it's a conflict error (user already has access) - if (tabloAccessError.code === "23505") { - return c.json({ error: "User already has access to this tablo" }, 409); + if (error) { + console.error("error", error); + return c.json({ error: error.message }, 500); } - return c.json({ error: tabloAccessError.message }, 500); - } + if (!inviteData) { + return c.json({ error: "Invalid token or email" }, 400); + } - // Mark invite as accepted instead of deleting (maintains audit trail) - await supabase.from("tablo_invites").update({ is_pending: false }).eq("id", invite_id); + const { id: invite_id, tablo_id, invited_by } = inviteData; - try { - const channel = streamServerClient.channel("messaging", tablo_id); - await channel.addMembers([joiner.id]); - } catch (error) { - console.error("error adding member to channel", error); - } + const { error: tabloAccessError } = await supabase.from("tablo_access").insert({ + tablo_id, + user_id: joiner.id, + // ** IMPORTANT ** + is_admin: false, + // ------------- + is_active: true, + granted_by: invited_by, + }); - return c.json({ tablo_id }); -}); + if (tabloAccessError) { + console.error("tabloAccessError", tabloAccessError); -tabloRouter.get("/members/:tablo_id", async (c) => { - const user = c.get("user"); - const supabase = c.get("supabase"); - const { tablo_id } = c.req.param(); + // Check if it's a conflict error (user already has access) + if (tabloAccessError.code === "23505") { + return c.json({ error: "User already has access to this tablo" }, 409); + } - const { data: tabloData, error: tabloError } = await supabase - .from("user_tablos") - .select("*") - .eq("id", tablo_id) - .eq("user_id", user.id); + return c.json({ error: tabloAccessError.message }, 500); + } - if (!tabloData || tabloData.length === 0) { - return c.json({ error: "You are not a member of this tablo" }, 403); - } + // Mark invite as accepted instead of deleting (maintains audit trail) + await supabase.from("tablo_invites").update({ is_pending: false }).eq("id", invite_id); - if (tabloError) { - return c.json({ error: "Internal server error" }, 500); - } + try { + const channel = streamServerClient.channel("messaging", tablo_id); + await channel.addMembers([joiner.id]); + } catch (error) { + console.error("error adding member to channel", error); + } - const { data, error } = await supabase - .from("tablo_access") - .select("is_admin, profiles(id, name, email)") - .eq("tablo_id", tablo_id) - .eq("is_active", true); - - const rows = data as unknown as { - is_admin: boolean; - profiles: { - id: string; - name: string; - email: string; - }; - }[]; - - if (error) { - return c.json({ error: error.message }, 500); - } - - return c.json({ - members: rows.map((member) => ({ - ...member.profiles, - is_admin: member.is_admin, - email: member.profiles.email, - })), + return c.json({ tablo_id }); }); -}); -tabloRouter.post("/leave", async (c) => { - const user = c.get("user"); - const supabase = c.get("supabase"); - const streamServerClient = c.get("streamServerClient"); - const { tablo_id } = await c.req.json(); + tabloRouter.get("/members/:tablo_id", async (c) => { + const user = c.get("user"); + const supabase = c.get("supabase"); + const { tablo_id } = c.req.param(); - const channel = streamServerClient.channel("messaging", tablo_id); - await channel.removeMembers([user.id]); + const { data: tabloData, error: tabloError } = await supabase + .from("user_tablos") + .select("*") + .eq("id", tablo_id) + .eq("user_id", user.id); - const { error } = await supabase - .from("tablo_access") - .update({ is_active: false }) - .eq("tablo_id", tablo_id) - .eq("user_id", user.id); + if (!tabloData || tabloData.length === 0) { + return c.json({ error: "You are not a member of this tablo" }, 403); + } - if (error) { - return c.json({ error: error.message }, 500); - } + if (tabloError) { + return c.json({ error: "Internal server error" }, 500); + } - return c.json({ message: "Tablo left successfully" }); -}); + const { data, error } = await supabase + .from("tablo_access") + .select("is_admin, profiles(id, name, email)") + .eq("tablo_id", tablo_id) + .eq("is_active", true); -tabloRouter.post("/webcal/generate-url", regularUserCheckMiddleware, async (c) => { - const user = c.get("user"); - const supabase = c.get("supabase"); - const s3_client = c.get("s3_client"); + const rows = data as unknown as { + is_admin: boolean; + profiles: { + id: string; + name: string; + email: string; + }; + }[]; - const { tablo_id } = await c.req.json(); + if (error) { + return c.json({ error: error.message }, 500); + } - if (tablo_id === null) { - return c.json({ error: "All tablos are not supported" }, 400); - } + return c.json({ + members: rows.map((member) => ({ + ...member.profiles, + is_admin: member.is_admin, + email: member.profiles.email, + })), + }); + }); - const { data: tabloData, error: tabloError } = await supabase - .from("tablos") - .select("name") - .eq("id", tablo_id) - .single(); + tabloRouter.post("/leave", async (c) => { + const user = c.get("user"); + const supabase = c.get("supabase"); + const streamServerClient = c.get("streamServerClient"); + const { tablo_id } = await c.req.json(); - if (tabloError || !tabloData) { - return c.json({ error: "Tablo not found" }, 404); - } + const channel = streamServerClient.channel("messaging", tablo_id); + await channel.removeMembers([user.id]); - const tabloName = tabloData.name.replace(/ /g, "_"); + const { error } = await supabase + .from("tablo_access") + .update({ is_active: false }) + .eq("tablo_id", tablo_id) + .eq("user_id", user.id); - const { data: accessData, error: accessError } = await supabase - .from("user_tablos") - .select("id") - .eq("id", tablo_id) - .eq("user_id", user.id) - .single(); + if (error) { + return c.json({ error: error.message }, 500); + } - if (accessError || !accessData) { - return c.json({ error: "Access denied to this tablo" }, 403); - } + return c.json({ message: "Tablo left successfully" }); + }); - const { data: subscriptionData } = await supabase - .from("calendar_subscriptions") - .select("*") - .eq("tablo_id", tablo_id) - .single(); + tabloRouter.post("/webcal/generate-url", middlewares.regularUserCheckMiddleware, async (c) => { + const user = c.get("user"); + const supabase = c.get("supabase"); + const s3_client = c.get("s3_client"); - // if (subscriptionError || !subscriptionData) { - // return c.json({ error: "Subscription already exists" }, 400); - // } + const { tablo_id } = await c.req.json(); - if (subscriptionData) { - const token = subscriptionData.token; + if (tablo_id === null) { + return c.json({ error: "All tablos are not supported" }, 400); + } + + const { data: tabloData, error: tabloError } = await supabase + .from("tablos") + .select("name") + .eq("id", tablo_id) + .single(); + + if (tabloError || !tabloData) { + return c.json({ error: "Tablo not found" }, 404); + } + + const tabloName = tabloData.name.replace(/ /g, "_"); + + const { data: accessData, error: accessError } = await supabase + .from("user_tablos") + .select("id") + .eq("id", tablo_id) + .eq("user_id", user.id) + .single(); + + if (accessError || !accessData) { + return c.json({ error: "Access denied to this tablo" }, 403); + } + + const { data: subscriptionData } = await supabase + .from("calendar_subscriptions") + .select("*") + .eq("tablo_id", tablo_id) + .single(); + + // if (subscriptionError || !subscriptionData) { + // return c.json({ error: "Subscription already exists" }, 400); + // } + + if (subscriptionData) { + const token = subscriptionData.token; + const httpUrl = `https://calendar.xtablo.com/${token}/${tabloName}.ics`; + + return c.json({ + webcal_url: null, + http_url: httpUrl, + }); + } + + const token = generateToken(); + + const { error } = await supabase.from("calendar_subscriptions").insert({ + tablo_id: tablo_id, + token: token, + }); + + if (error) { + return c.json({ error: "Failed to generate token" }, 500); + } + + try { + await writeCalendarFileToR2(s3_client, supabase, { + token, + tabloName, + tablo_id, + }); + } catch (error) { + console.error("error writing calendar file to R2", error); + return c.json({ error: "Failed to write calendar file to R2" }, 500); + } + + // Return the webcal URL + // const webcalUrl = `webcal://${ + // c.req.header("host") || "localhost:3000" + // }/api/v1/tablos/webcal/${tablo_id}/${token}`; const httpUrl = `https://calendar.xtablo.com/${token}/${tabloName}.ics`; return c.json({ webcal_url: null, http_url: httpUrl, }); - } - - const token = generateToken(); - - const { error } = await supabase.from("calendar_subscriptions").insert({ - tablo_id: tablo_id, - token: token, }); - if (error) { - return c.json({ error: "Failed to generate token" }, 500); - } - - try { - await writeCalendarFileToR2(s3_client, supabase, { - token, - tabloName, - tablo_id, - }); - } catch (error) { - console.error("error writing calendar file to R2", error); - return c.json({ error: "Failed to write calendar file to R2" }, 500); - } - - // Return the webcal URL - // const webcalUrl = `webcal://${ - // c.req.header("host") || "localhost:3000" - // }/api/v1/tablos/webcal/${tablo_id}/${token}`; - const httpUrl = `https://calendar.xtablo.com/${token}/${tabloName}.ics`; - - return c.json({ - webcal_url: null, - http_url: httpUrl, - }); -}); + return tabloRouter; +}; diff --git a/api/src/tablo_data.ts b/api/src/tablo_data.ts index a119fb9..47f8a9a 100644 --- a/api/src/tablo_data.ts +++ b/api/src/tablo_data.ts @@ -2,178 +2,177 @@ import { PutObjectCommand, type S3Client } from "@aws-sdk/client-s3"; import type { SupabaseClient, User } from "@supabase/supabase-js"; import { Hono } from "hono"; import { checkTabloAdmin, checkTabloMember, getTabloFileNames } from "./helpers.js"; -import { - authMiddleware, - r2Middleware, - regularUserCheckMiddleware, - streamChatMiddleware, -} from "./middleware.js"; +import type { Middlewares } from "./middleware.js"; -export const tabloDataRouter = new Hono<{ - Variables: { - user: User; - supabase: SupabaseClient; - s3_client: S3Client; - }; -}>(); +export const getTabloDataRouter = (middlewares: Middlewares) => { + const tabloDataRouter = new Hono<{ + Variables: { + user: User; + supabase: SupabaseClient; + s3_client: S3Client; + }; + }>(); -tabloDataRouter.use(authMiddleware); -tabloDataRouter.use(streamChatMiddleware); -tabloDataRouter.use(r2Middleware); + tabloDataRouter.use(middlewares.authMiddleware); + tabloDataRouter.use(middlewares.streamChatMiddleware); + tabloDataRouter.use(middlewares.r2Middleware); -// GET /tablo-data/:tabloId/filenames - Get all files for a tablo -tabloDataRouter.get("/:tabloId/filenames", checkTabloMember, async (c) => { - const tabloId = c.req.param("tabloId"); - const s3_client = c.get("s3_client"); + // GET /tablo-data/:tabloId/filenames - Get all files for a tablo + tabloDataRouter.get("/:tabloId/filenames", checkTabloMember, async (c) => { + const tabloId = c.req.param("tabloId"); + const s3_client = c.get("s3_client"); - try { - const fileNames = await getTabloFileNames(s3_client, tabloId); - return c.json({ fileNames: fileNames || [] }); - } catch (error) { - console.error("Error fetching tablo files:", error); - return c.json({ error: "Failed to fetch tablo files" }, 500); - } -}); - -// GET /tablo-data/:tabloId/:fileName - Get a specific file -tabloDataRouter.get("/:tabloId/:fileName", checkTabloMember, async (c) => { - const tabloId = c.req.param("tabloId"); - const fileName = c.req.param("fileName"); - - const s3_client = c.get("s3_client"); - - try { - const { GetObjectCommand } = await import("@aws-sdk/client-s3"); - - const response = await s3_client.send( - new GetObjectCommand({ - Bucket: "tablo-data", - Key: `${tabloId}/${fileName}`, - }) - ); - - if (!response.Body) { - return c.json({ error: "File not found" }, 404); + try { + const fileNames = await getTabloFileNames(s3_client, tabloId); + return c.json({ fileNames: fileNames || [] }); + } catch (error) { + console.error("Error fetching tablo files:", error); + return c.json({ error: "Failed to fetch tablo files" }, 500); } + }); - const content = await response.Body.transformToString(); - - return c.json({ - fileName, - content, - contentType: response.ContentType, - lastModified: response.LastModified, - }); - } catch (error) { - console.error("Error fetching file:", error); - return c.json({ error: "Failed to fetch file" }, 500); - } -}); - -// POST /tablo-data/:tabloId/:fileName - Create or update a file -tabloDataRouter.post( - "/:tabloId/:fileName", - regularUserCheckMiddleware, - checkTabloMember, - async (c) => { + // GET /tablo-data/:tabloId/:fileName - Get a specific file + tabloDataRouter.get("/:tabloId/:fileName", checkTabloMember, async (c) => { const tabloId = c.req.param("tabloId"); const fileName = c.req.param("fileName"); const s3_client = c.get("s3_client"); try { - const body = await c.req.json(); - const { content, contentType = "text/plain" } = body; + const { GetObjectCommand } = await import("@aws-sdk/client-s3"); - if (!content) { - return c.json({ error: "Content is required" }, 400); + const response = await s3_client.send( + new GetObjectCommand({ + Bucket: "tablo-data", + Key: `${tabloId}/${fileName}`, + }) + ); + + if (!response.Body) { + return c.json({ error: "File not found" }, 404); } - await s3_client.send( - new PutObjectCommand({ - Bucket: "tablo-data", - Key: `${tabloId}/${fileName}`, - Body: content, - ContentType: contentType, - }) - ); + const content = await response.Body.transformToString(); return c.json({ - message: "File uploaded successfully", fileName, - tabloId, + content, + contentType: response.ContentType, + lastModified: response.LastModified, }); } catch (error) { - console.error("Error uploading file:", error); - return c.json({ error: "Failed to upload file" }, 500); + console.error("Error fetching file:", error); + return c.json({ error: "Failed to fetch file" }, 500); } - } -); + }); -// // PUT /tablo-data/:tabloId/:fileName - Update a file -// tabloDataRouter.put("/:tabloId/:fileName", async (c) => { -// const tabloId = c.req.param("tabloId"); -// const fileName = c.req.param("fileName"); -// const s3_client = c.get("s3_client"); + // POST /tablo-data/:tabloId/:fileName - Create or update a file + tabloDataRouter.post( + "/:tabloId/:fileName", + middlewares.regularUserCheckMiddleware, + checkTabloMember, + async (c) => { + const tabloId = c.req.param("tabloId"); + const fileName = c.req.param("fileName"); -// try { -// const body = await c.req.json(); -// const { content, contentType = "text/plain" } = body; + const s3_client = c.get("s3_client"); -// if (!content) { -// return c.json({ error: "Content is required" }, 400); -// } + try { + const body = await c.req.json(); + const { content, contentType = "text/plain" } = body; -// const { PutObjectCommand } = await import("@aws-sdk/client-s3"); + if (!content) { + return c.json({ error: "Content is required" }, 400); + } -// await s3_client.send( -// new PutObjectCommand({ -// Bucket: "tablo-data", -// Key: `${tabloId}/${fileName}`, -// Body: content, -// ContentType: contentType, -// }) -// ); + await s3_client.send( + new PutObjectCommand({ + Bucket: "tablo-data", + Key: `${tabloId}/${fileName}`, + Body: content, + ContentType: contentType, + }) + ); -// return c.json({ -// message: "File updated successfully", -// fileName, -// tabloId, -// }); -// } catch (error) { -// console.error("Error updating file:", error); -// return c.json({ error: "Failed to update file" }, 500); -// } -// }); - -// DELETE /tablo-data/:tabloId/:fileName - Delete a file -tabloDataRouter.delete( - "/:tabloId/:fileName", - regularUserCheckMiddleware, - checkTabloAdmin, - async (c) => { - const tabloId = c.req.param("tabloId"); - const fileName = c.req.param("fileName"); - const s3_client = c.get("s3_client"); - - try { - const { DeleteObjectCommand } = await import("@aws-sdk/client-s3"); - - await s3_client.send( - new DeleteObjectCommand({ - Bucket: "tablo-data", - Key: `${tabloId}/${fileName}`, - }) - ); - - return c.json({ - message: "File deleted successfully", - fileName, - tabloId, - }); - } catch (error) { - console.error("Error deleting file:", error); - return c.json({ error: "Failed to delete file" }, 500); + return c.json({ + message: "File uploaded successfully", + fileName, + tabloId, + }); + } catch (error) { + console.error("Error uploading file:", error); + return c.json({ error: "Failed to upload file" }, 500); + } } - } -); + ); + + // // PUT /tablo-data/:tabloId/:fileName - Update a file + // tabloDataRouter.put("/:tabloId/:fileName", async (c) => { + // const tabloId = c.req.param("tabloId"); + // const fileName = c.req.param("fileName"); + // const s3_client = c.get("s3_client"); + + // try { + // const body = await c.req.json(); + // const { content, contentType = "text/plain" } = body; + + // if (!content) { + // return c.json({ error: "Content is required" }, 400); + // } + + // const { PutObjectCommand } = await import("@aws-sdk/client-s3"); + + // await s3_client.send( + // new PutObjectCommand({ + // Bucket: "tablo-data", + // Key: `${tabloId}/${fileName}`, + // Body: content, + // ContentType: contentType, + // }) + // ); + + // return c.json({ + // message: "File updated successfully", + // fileName, + // tabloId, + // }); + // } catch (error) { + // console.error("Error updating file:", error); + // return c.json({ error: "Failed to update file" }, 500); + // } + // }); + + // DELETE /tablo-data/:tabloId/:fileName - Delete a file + tabloDataRouter.delete( + "/:tabloId/:fileName", + middlewares.regularUserCheckMiddleware, + checkTabloAdmin, + async (c) => { + const tabloId = c.req.param("tabloId"); + const fileName = c.req.param("fileName"); + const s3_client = c.get("s3_client"); + + try { + const { DeleteObjectCommand } = await import("@aws-sdk/client-s3"); + + await s3_client.send( + new DeleteObjectCommand({ + Bucket: "tablo-data", + Key: `${tabloId}/${fileName}`, + }) + ); + + return c.json({ + message: "File deleted successfully", + fileName, + tabloId, + }); + } catch (error) { + console.error("Error deleting file:", error); + return c.json({ error: "Failed to delete file" }, 500); + } + } + ); + + return tabloDataRouter; +}; diff --git a/api/src/tasks.ts b/api/src/tasks.ts index e8ace72..9af4af5 100644 --- a/api/src/tasks.ts +++ b/api/src/tasks.ts @@ -1,105 +1,113 @@ import { S3Client } from "@aws-sdk/client-s3"; +import type { StripeSync } from "@supabase/stripe-sync-engine"; import type { SupabaseClient } from "@supabase/supabase-js"; -import { Hono, type Context } from "hono"; -import { config } from "./config.js"; -import { writeCalendarFileToR2 } from "./helpers.js"; -import { streamChatMiddleware } from "./middleware.js"; +import { type Context, Hono } from "hono"; import type { StreamChat } from "stream-chat"; -import { stripeSync } from "./stripeSync.js"; +import type { AppConfig } from "./config.js"; +import { writeCalendarFileToR2 } from "./helpers.js"; +import type { Middlewares } from "./middleware.js"; -export const taskRouter = new Hono<{ - Variables: { supabase: SupabaseClient }; -}>(); +export const getTaskRouter = ( + middlewares: Middlewares, + config: AppConfig, + stripeSync: StripeSync +) => { + const taskRouter = new Hono<{ + Variables: { supabase: SupabaseClient }; + }>(); -taskRouter.post("/sync-calendars", async (c) => { - const supabase = c.get("supabase"); - if (c.req.header("Authorization") !== `Basic ${config.TASKS_SECRET}`) { - return c.json({ error: "Unauthorized" }, 401); - } - - const s3 = new S3Client({ - region: "auto", - endpoint: `https://${config.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`, - credentials: { - accessKeyId: config.R2_ACCESS_KEY_ID, - secretAccessKey: config.R2_SECRET_ACCESS_KEY, - }, - }); - const { data, error } = await supabase - .from("calendar_subscriptions") - .select("token, tablo_id, tablos(name)"); - if (error) { - return c.json({ error: error.message }, 500); - } - - const calendarSubscriptionsData = data as unknown as [ - { - token: string; - tablo_id: string; - tablos: { name: string }; - }, - ]; - - calendarSubscriptionsData.forEach(async (subscription) => { - const tabloName = subscription.tablos.name.replace(/ /g, "_"); - await writeCalendarFileToR2(s3, supabase, { - tabloName, - token: subscription.token, - tablo_id: subscription.tablo_id, - }); - }); - - return c.json({ message: "Synced calendars" }); -}); - -taskRouter.post( - "/sync-tablo-names", - streamChatMiddleware, - async ( - c: Context<{ Variables: { supabase: SupabaseClient; streamServerClient: StreamChat } }> - ) => { + taskRouter.post("/sync-calendars", async (c) => { const supabase = c.get("supabase"); - const streamServerClient = c.get("streamServerClient"); - if (c.req.header("Authorization") !== `Basic ${config.TASKS_SECRET}`) { return c.json({ error: "Unauthorized" }, 401); } - const fifteenMinutesInMilliseconds = 1000 * 60 * 15; - + const s3 = new S3Client({ + region: "auto", + endpoint: `https://${config.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`, + credentials: { + accessKeyId: config.R2_ACCESS_KEY_ID, + secretAccessKey: config.R2_SECRET_ACCESS_KEY, + }, + }); const { data, error } = await supabase - .from("tablos") - .select("id, name") - .gt("updated_at", new Date(Date.now() - fifteenMinutesInMilliseconds).toISOString()); - + .from("calendar_subscriptions") + .select("token, tablo_id, tablos(name)"); if (error) { return c.json({ error: error.message }, 500); } - const tablosData = data as { id: string; name: string }[]; + const calendarSubscriptionsData = data as unknown as [ + { + token: string; + tablo_id: string; + tablos: { name: string }; + }, + ]; - tablosData.forEach(async (tablo) => { - const channel = streamServerClient.channel("messaging", tablo.id); - try { - await channel.update({ - // @ts-ignore - name: tablo.name, - }); - } catch (error) { - console.error(`error updating channel, tablo id: ${tablo.id}, error: ${error}`); - } + calendarSubscriptionsData.forEach(async (subscription) => { + const tabloName = subscription.tablos.name.replace(/ /g, "_"); + await writeCalendarFileToR2(s3, supabase, { + tabloName, + token: subscription.token, + tablo_id: subscription.tablo_id, + }); }); - return c.json({ message: `Synced ${tablosData.length} tablo names` }); - } -); + return c.json({ message: "Synced calendars" }); + }); -taskRouter.post("/sync-stripe-subscriptions", async (c) => { - if (c.req.header("Authorization") !== `Basic ${config.TASKS_SECRET}`) { - return c.json({ error: "Unauthorized" }, 401); - } + taskRouter.post( + "/sync-tablo-names", + middlewares.streamChatMiddleware, + async ( + c: Context<{ Variables: { supabase: SupabaseClient; streamServerClient: StreamChat } }> + ) => { + const supabase = c.get("supabase"); + const streamServerClient = c.get("streamServerClient"); - const data = await stripeSync.syncBackfill({ object: "all" }); + if (c.req.header("Authorization") !== `Basic ${config.TASKS_SECRET}`) { + return c.json({ error: "Unauthorized" }, 401); + } - return c.json({ message: `Synced ${data.subscriptions?.synced} stripe subscriptions` }); -}); + const fifteenMinutesInMilliseconds = 1000 * 60 * 15; + + const { data, error } = await supabase + .from("tablos") + .select("id, name") + .gt("updated_at", new Date(Date.now() - fifteenMinutesInMilliseconds).toISOString()); + + if (error) { + return c.json({ error: error.message }, 500); + } + + const tablosData = data as { id: string; name: string }[]; + + tablosData.forEach(async (tablo) => { + const channel = streamServerClient.channel("messaging", tablo.id); + try { + await channel.update({ + // @ts-ignore + name: tablo.name, + }); + } catch (error) { + console.error(`error updating channel, tablo id: ${tablo.id}, error: ${error}`); + } + }); + + return c.json({ message: `Synced ${tablosData.length} tablo names` }); + } + ); + + taskRouter.post("/sync-stripe-subscriptions", async (c) => { + if (c.req.header("Authorization") !== `Basic ${config.TASKS_SECRET}`) { + return c.json({ error: "Unauthorized" }, 401); + } + + const data = await stripeSync.syncBackfill({ object: "all" }); + + return c.json({ message: `Synced ${data.subscriptions?.synced} stripe subscriptions` }); + }); + + return taskRouter; +}; diff --git a/api/src/transporter.ts b/api/src/transporter.ts index 42df093..bfcc6aa 100644 --- a/api/src/transporter.ts +++ b/api/src/transporter.ts @@ -1,10 +1,10 @@ import { google } from "googleapis"; import nodemailer from "nodemailer"; -import { config } from "./config.js"; +import type { AppConfig } from "./config.js"; const OAuth2 = google.auth.OAuth2; -export const createTransporter = () => { +export const createTransporter = (config: AppConfig) => { const oauth2Client = new OAuth2( config.EMAIL_CLIENT_ID, config.EMAIL_CLIENT_SECRET, @@ -30,5 +30,3 @@ export const createTransporter = () => { return transporter; }; - -export const transporter = createTransporter(); diff --git a/api/src/user.ts b/api/src/user.ts index 85dcea4..140ba9e 100644 --- a/api/src/user.ts +++ b/api/src/user.ts @@ -9,96 +9,95 @@ import { Hono } from "hono"; import type { Transporter } from "nodemailer"; import { StreamChat } from "stream-chat"; import type { Tables } from "./database.types.ts"; -import { authMiddleware, r2Middleware, streamChatMiddleware } from "./middleware.js"; -import { transporter } from "./transporter.js"; +import type { Middlewares } from "./middleware.js"; -export const userRouter = new Hono<{ - Variables: { - user: User; - supabase: SupabaseClient; - transporter: Transporter; - streamServerClient: StreamChat; - s3_client: S3Client; - }; -}>(); +export const getUserRouter = (middlewares: Middlewares, transporter: Transporter) => { + const userRouter = new Hono<{ + Variables: { + user: User; + supabase: SupabaseClient; + streamServerClient: StreamChat; + s3_client: S3Client; + }; + }>(); -userRouter.use(authMiddleware); -userRouter.use(streamChatMiddleware); -userRouter.use(r2Middleware); + userRouter.use(middlewares.authMiddleware); + userRouter.use(middlewares.streamChatMiddleware); + userRouter.use(middlewares.r2Middleware); -userRouter.post("/sign-up-to-stream", async (c) => { - const { id } = c.get("user"); - const supabase = c.get("supabase"); + userRouter.post("/sign-up-to-stream", async (c) => { + const { id } = c.get("user"); + const supabase = c.get("supabase"); - const { data } = await supabase.from("profiles").select("*").eq("id", id).single(); + const { data } = await supabase.from("profiles").select("*").eq("id", id).single(); - const user = data as Tables<"profiles">; + const user = data as Tables<"profiles">; - const streamServerClient = c.get("streamServerClient"); - await streamServerClient.upsertUser({ - id, - name: user.name ?? "", - language: "fr", + const streamServerClient = c.get("streamServerClient"); + await streamServerClient.upsertUser({ + id, + name: user.name ?? "", + language: "fr", + }); + + return c.json({ + message: "User signed up to stream", + }); }); - return c.json({ - message: "User signed up to stream", + userRouter.get("/me", async (c) => { + const user = c.get("user"); + const supabase = c.get("supabase"); + const streamServerClient = c.get("streamServerClient"); + + const { data, error } = await supabase.from("profiles").select("*").eq("id", user.id).single(); + + const userData = data as Tables<"profiles">; + + if (!userData) { + return c.json({ error: "User not found" }, 404); + } + + if (error) { + return c.json({ error: error.message }, 500); + } + + const user_id = data.id; + const token = streamServerClient.createToken(user_id); + + return c.json({ + ...userData, + streamToken: token, + }); }); -}); -userRouter.get("/me", async (c) => { - const user = c.get("user"); - const supabase = c.get("supabase"); - const streamServerClient = c.get("streamServerClient"); + userRouter.post("/mark-temporary", async (c) => { + const user = c.get("user"); + const supabase = c.get("supabase"); - const { data, error } = await supabase.from("profiles").select("*").eq("id", user.id).single(); + const body = await c.req.json(); + const { temporary_password } = body; - const userData = data as Tables<"profiles">; + const { data: profile, error } = await supabase + .from("profiles") + .update({ + is_temporary: true, + }) + .eq("id", user.id) + .select() + .single(); - if (!userData) { - return c.json({ error: "User not found" }, 404); - } + if (error) { + return c.json({ error: error.message }, 500); + } - if (error) { - return c.json({ error: error.message }, 500); - } - - const user_id = data.id; - const token = streamServerClient.createToken(user_id); - - return c.json({ - ...userData, - streamToken: token, - }); -}); - -userRouter.post("/mark-temporary", async (c) => { - const user = c.get("user"); - const supabase = c.get("supabase"); - - const body = await c.req.json(); - const { temporary_password } = body; - - const { data: profile, error } = await supabase - .from("profiles") - .update({ - is_temporary: true, - }) - .eq("id", user.id) - .select() - .single(); - - if (error) { - return c.json({ error: error.message }, 500); - } - - try { - if (profile?.email && transporter) { - const mailOptions = { - from: "Xtablo ", - to: profile.email, - subject: "Bienvenue sur XTablo - Votre mot de passe temporaire", - text: `Bienvenue sur XTablo ! + try { + if (profile?.email && transporter) { + const mailOptions = { + from: "Xtablo ", + to: profile.email, + subject: "Bienvenue sur XTablo - Votre mot de passe temporaire", + text: `Bienvenue sur XTablo ! Votre compte a été créé avec succès. Voici vos informations de connexion : @@ -111,7 +110,7 @@ Connectez-vous sur : ${process.env.FRONTEND_URL || "https://app.xtablo.com"} Cordialement, L'équipe XTablo`, - html: ` + html: `

Bienvenue sur XTablo !

@@ -137,143 +136,146 @@ L'équipe XTablo`,

`, - }; - await transporter.sendMail(mailOptions); + }; + await transporter.sendMail(mailOptions); + } + } catch (error) { + console.error("Failed to send welcome email:", error); } - } catch (error) { - console.error("Failed to send welcome email:", error); - } - return c.json({ - message: "User marked as temporary", + return c.json({ + message: "User marked as temporary", + }); }); -}); -// userRouter.put("/profile", async (c) => { -// const user = c.get("user"); -// const supabase = c.get("supabase"); + // userRouter.put("/profile", async (c) => { + // const user = c.get("user"); + // const supabase = c.get("supabase"); -// const body = await c.req.json(); -// const { first_name, last_name } = body; + // const body = await c.req.json(); + // const { first_name, last_name } = body; -// // Deprecated: name field is deprecated, use first_name and last_name instead -// // Combine first_name and last_name into a single name field -// const name = [first_name, last_name].filter(Boolean).join(" "); + // // Deprecated: name field is deprecated, use first_name and last_name instead + // // Combine first_name and last_name into a single name field + // const name = [first_name, last_name].filter(Boolean).join(" "); -// const updateData = -// first_name && last_name -// ? { -// name, -// first_name, -// last_name, -// } -// : {}; + // const updateData = + // first_name && last_name + // ? { + // name, + // first_name, + // last_name, + // } + // : {}; -// const { data: profile, error } = await supabase -// .from("profiles") -// .update(updateData) -// .eq("id", user.id) -// .select() -// .single(); + // const { data: profile, error } = await supabase + // .from("profiles") + // .update(updateData) + // .eq("id", user.id) + // .select() + // .single(); -// if (error) { -// return c.json({ error: error.message }, 500); -// } + // if (error) { + // return c.json({ error: error.message }, 500); + // } -// return c.json({ -// message: "Profile updated successfully", -// profile, -// }); -// }); + // return c.json({ + // message: "Profile updated successfully", + // profile, + // }); + // }); -userRouter.post("/profile/avatar", async (c) => { - const user = c.get("user"); - const supabase = c.get("supabase"); - const s3Client = c.get("s3_client"); + userRouter.post("/profile/avatar", async (c) => { + const user = c.get("user"); + const supabase = c.get("supabase"); + const s3Client = c.get("s3_client"); - const body = await c.req.json(); - const { content, contentType = "image/jpeg" } = body; + const body = await c.req.json(); + const { content, contentType = "image/jpeg" } = body; - if (!content) { - return c.json({ error: "Content is required" }, 400); - } + if (!content) { + return c.json({ error: "Content is required" }, 400); + } - const randomString = Math.random().toString(36).substring(2, 15); - const base64Content = Buffer.from(content, "base64"); - const key = `${user.id}/public_avatar_${randomString}.${contentType.split("/")[1]}`; + const randomString = Math.random().toString(36).substring(2, 15); + const base64Content = Buffer.from(content, "base64"); + const key = `${user.id}/public_avatar_${randomString}.${contentType.split("/")[1]}`; - try { - await s3Client.send( - new PutObjectCommand({ - Bucket: "web-assets", - Key: key, - Body: base64Content, - ContentType: contentType, - ContentEncoding: "base64", - }) - ); - } catch (error) { - console.error("Failed to upload avatar:", error); - return c.json({ error: "Failed to upload avatar" }, 500); - } + try { + await s3Client.send( + new PutObjectCommand({ + Bucket: "web-assets", + Key: key, + Body: base64Content, + ContentType: contentType, + ContentEncoding: "base64", + }) + ); + } catch (error) { + console.error("Failed to upload avatar:", error); + return c.json({ error: "Failed to upload avatar" }, 500); + } - const avatarUrl = `https://assets.xtablo.com/${key}`; + const avatarUrl = `https://assets.xtablo.com/${key}`; - const { data, error } = await supabase - .from("profiles") - .update({ avatar_url: avatarUrl }) - .eq("id", user.id) - .select() - .single(); + const { data, error } = await supabase + .from("profiles") + .update({ avatar_url: avatarUrl }) + .eq("id", user.id) + .select() + .single(); - if (error) { - return c.json({ error: error.message }, 500); - } + if (error) { + return c.json({ error: error.message }, 500); + } - return c.json({ - message: "Avatar updated successfully", - profile: data, + return c.json({ + message: "Avatar updated successfully", + profile: data, + }); }); -}); -userRouter.delete("/profile/avatar", async (c) => { - const user = c.get("user"); - const supabase = c.get("supabase"); - const s3Client = c.get("s3_client"); + userRouter.delete("/profile/avatar", async (c) => { + const user = c.get("user"); + const supabase = c.get("supabase"); + const s3Client = c.get("s3_client"); - try { - const listedObjects = await s3Client.send( - new ListObjectsV2Command({ - Bucket: "web-assets", - Prefix: `${user.id}/`, - }) - ); + try { + const listedObjects = await s3Client.send( + new ListObjectsV2Command({ + Bucket: "web-assets", + Prefix: `${user.id}/`, + }) + ); - if (listedObjects.Contents.length === 0) return c.json({ error: "No objects found" }, 404); + if (listedObjects.Contents.length === 0) return c.json({ error: "No objects found" }, 404); - await s3Client.send( - new DeleteObjectsCommand({ - Bucket: "web-assets", - Delete: { Objects: listedObjects.Contents.map(({ Key }) => ({ Key })) }, - }) - ); - } catch (error) { - console.error("Failed to delete avatar:", error); - return c.json({ error: "Failed to delete avatar" }, 500); - } + await s3Client.send( + new DeleteObjectsCommand({ + Bucket: "web-assets", + Delete: { Objects: listedObjects.Contents.map(({ Key }) => ({ Key })) }, + }) + ); + } catch (error) { + console.error("Failed to delete avatar:", error); + return c.json({ error: "Failed to delete avatar" }, 500); + } - const { error } = await supabase - .from("profiles") - .update({ avatar_url: null }) - .eq("id", user.id) - .select() - .single(); + const { error } = await supabase + .from("profiles") + .update({ avatar_url: null }) + .eq("id", user.id) + .select() + .single(); - if (error) { - return c.json({ error: error.message }, 500); - } + if (error) { + return c.json({ error: error.message }, 500); + } - return c.json({ - message: "Avatar deleted successfully", + return c.json({ + message: "Avatar deleted successfully", + }); }); -}); + + return userRouter; +}; diff --git a/docs/GOOGLE_SECRET_MANAGER_SETUP.md b/docs/GOOGLE_SECRET_MANAGER_SETUP.md new file mode 100644 index 0000000..74f522e --- /dev/null +++ b/docs/GOOGLE_SECRET_MANAGER_SETUP.md @@ -0,0 +1,299 @@ +# Google Secret Manager Setup + +This guide explains how to set up and use Google Secret Manager for storing application secrets in production and staging environments. + +## Overview + +The application uses **Google Secret Manager** to securely store sensitive configuration values (API keys, database credentials, etc.) in production and staging environments, while using local `.env` files for development. + +### Environment-based Configuration + +- **Development/Test**: Uses `.env.development` or `.env.test` files (via dotenv) +- **Production/Staging**: Uses Google Secret Manager + +## Prerequisites + +1. **Google Cloud Project**: You need a GCP project with Secret Manager API enabled +2. **Service Account**: A service account with Secret Manager access +3. **Authentication**: Proper credentials configured for your deployment environment + +## Setup Steps + +### 1. Enable Secret Manager API + +```bash +# Enable the Secret Manager API in your GCP project +gcloud services enable secretmanager.googleapis.com --project=YOUR_PROJECT_ID +``` + +### 2. Create Secrets in Google Cloud + +For each required secret, create it in Google Secret Manager: + +```bash +# Example: Create SUPABASE_URL secret +echo -n "https://your-project.supabase.co" | \ + gcloud secrets create SUPABASE_URL \ + --data-file=- \ + --project=YOUR_PROJECT_ID + +# Example: Create STRIPE_SECRET_KEY secret +echo -n "sk_live_xxxxx" | \ + gcloud secrets create STRIPE_SECRET_KEY \ + --data-file=- \ + --project=YOUR_PROJECT_ID +``` + +### Required Secrets + +Create the following secrets in Google Secret Manager: + +| Secret Name | Description | Example | +|-------------|-------------|---------| +| `SUPABASE_URL` | Supabase project URL | `https://xxx.supabase.co` | +| `SUPABASE_SERVICE_ROLE_KEY` | Supabase service role key | `eyJxxx...` | +| `SUPABASE_CONNECTION_STRING` | PostgreSQL connection string | `postgresql://...` | +| `SUPABASE_CA_CERT` | Supabase CA certificate (optional) | `-----BEGIN CERTIFICATE-----...` | +| `STREAM_CHAT_API_KEY` | Stream Chat API key | `xxx` | +| `STREAM_CHAT_API_SECRET` | Stream Chat API secret | `xxx` | +| `STRIPE_SECRET_KEY` | Stripe secret key | `sk_live_xxx` | +| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret | `whsec_xxx` | +| `EMAIL_USER` | Gmail/email account | `yourapp@gmail.com` | +| `EMAIL_CLIENT_ID` | OAuth client ID for email | `xxx.apps.googleusercontent.com` | +| `EMAIL_CLIENT_SECRET` | OAuth client secret | `xxx` | +| `EMAIL_REFRESH_TOKEN` | OAuth refresh token | `xxx` | +| `R2_ACCOUNT_ID` | Cloudflare R2 account ID | `xxx` | +| `R2_ACCESS_KEY_ID` | Cloudflare R2 access key | `xxx` | +| `R2_SECRET_ACCESS_KEY` | Cloudflare R2 secret key | `xxx` | +| `TASKS_SECRET` | Secret for task authentication | `xxx` | + +### 3. Grant Service Account Access + +Your service account needs permission to access secrets: + +```bash +# Grant Secret Manager Secret Accessor role +gcloud projects add-iam-policy-binding YOUR_PROJECT_ID \ + --member="serviceAccount:YOUR_SERVICE_ACCOUNT@YOUR_PROJECT_ID.iam.gserviceaccount.com" \ + --role="roles/secretmanager.secretAccessor" +``` + +### 4. Set Environment Variables + +The application needs these environment variables to work with Google Secret Manager: + +```bash +# Required in production/staging +export GCP_PROJECT=your-project-id # or GOOGLE_CLOUD_PROJECT +export NODE_ENV=production # or staging + +# Optional: Use non-default port +export PORT=8080 + +# Optional: Override CORS origins +export CORS_ORIGIN=https://app.xtablo.com + +# Optional: Override XTablo URL +export XTABLO_URL=https://app.xtablo.com +``` + +## Authentication + +### Local Development (Testing Secret Manager) + +For local testing of Secret Manager integration: + +```bash +# Authenticate with gcloud +gcloud auth application-default login + +# Set project +export GCP_PROJECT=your-project-id +export NODE_ENV=staging + +# Run your app +npm run dev +``` + +### Google Cloud Run / Cloud Functions + +Authentication is automatic when running on Google Cloud services. Just ensure: + +1. The service account has the `roles/secretmanager.secretAccessor` role +2. The `GCP_PROJECT` environment variable is set (or use the project's default) + +```yaml +# Example Cloud Run configuration (cloudbuild.yaml) +steps: + - name: 'gcr.io/cloud-builders/docker' + args: ['build', '-t', 'gcr.io/$PROJECT_ID/api:$COMMIT_SHA', '.'] + - name: 'gcr.io/cloud-builders/docker' + args: ['push', 'gcr.io/$PROJECT_ID/api:$COMMIT_SHA'] + - name: 'gcr.io/google.com/cloudsdktool/cloud-sdk' + entrypoint: gcloud + args: + - 'run' + - 'deploy' + - 'xtablo-api' + - '--image=gcr.io/$PROJECT_ID/api:$COMMIT_SHA' + - '--region=us-central1' + - '--platform=managed' + - '--set-env-vars=NODE_ENV=production,GCP_PROJECT=$PROJECT_ID' + - '--service-account=YOUR_SERVICE_ACCOUNT@$PROJECT_ID.iam.gserviceaccount.com' +``` + +## Usage in Code + +### Accessing Configuration + +The configuration is loaded asynchronously at startup: + +```typescript +// In index.ts (server startup) +import { getConfig } from './config.js'; + +async function startServer() { + const config = await getConfig(); + // Use config.STRIPE_SECRET_KEY, config.SUPABASE_URL, etc. +} +``` + +### Backwards Compatibility + +For synchronous access (after initialization): + +```typescript +import { config } from './config.js'; + +// This works ONLY after getConfig() has been called +// (automatically done in index.ts startup) +const stripeKey = config.STRIPE_SECRET_KEY; +``` + +### Manual Secret Loading + +If you need to load additional secrets not in the main config: + +```typescript +import { loadSecret } from './secretManager.js'; + +// Load a single secret +const apiKey = await loadSecret('MY_CUSTOM_SECRET'); + +// Load with fallback to environment variable +import { loadSecretWithFallback } from './secretManager.js'; +const token = await loadSecretWithFallback('MY_TOKEN', 'MY_TOKEN_ENV_VAR'); +``` + +## Secret Versioning + +Google Secret Manager supports versioning: + +```bash +# Add a new version of a secret +echo -n "new-secret-value" | \ + gcloud secrets versions add SUPABASE_URL \ + --data-file=- \ + --project=YOUR_PROJECT_ID + +# The app automatically uses the "latest" version +# You can also specify a specific version in code if needed +``` + +## Security Best Practices + +1. **Never commit secrets** to version control +2. **Use different secrets** for each environment (dev, staging, production) +3. **Rotate secrets regularly** using Secret Manager versioning +4. **Limit access** to Secret Manager using IAM roles +5. **Audit access** using Cloud Audit Logs +6. **Use service accounts** with minimal permissions + +## Troubleshooting + +### "Missing required environment variable" error + +**Problem**: App fails to start with validation errors + +**Solution**: Ensure all required secrets are created in Secret Manager + +```bash +# List all secrets in your project +gcloud secrets list --project=YOUR_PROJECT_ID + +# View a specific secret's value +gcloud secrets versions access latest --secret=SUPABASE_URL --project=YOUR_PROJECT_ID +``` + +### "Permission denied" errors + +**Problem**: App can't access secrets + +**Solution**: Check IAM permissions + +```bash +# Check service account permissions +gcloud projects get-iam-policy YOUR_PROJECT_ID \ + --flatten="bindings[].members" \ + --filter="bindings.members:serviceAccount:YOUR_SERVICE_ACCOUNT@YOUR_PROJECT_ID.iam.gserviceaccount.com" +``` + +### "GCP_PROJECT environment variable must be set" + +**Problem**: App doesn't know which GCP project to use + +**Solution**: Set the environment variable + +```bash +export GCP_PROJECT=your-project-id +# or +export GOOGLE_CLOUD_PROJECT=your-project-id +``` + +### Development mode still uses Secret Manager + +**Problem**: Want to use dotenv in development but it's using Secret Manager + +**Solution**: Ensure `NODE_ENV` is set to `development` + +```bash +export NODE_ENV=development +npm run dev +``` + +## Cost Considerations + +Google Secret Manager pricing (as of 2024): + +- **Secret versions**: $0.06 per active secret version per month +- **Access operations**: $0.03 per 10,000 accesses + +For typical usage: +- ~15 secrets = ~$1/month +- Caching reduces access operations (app caches secrets after first load) + +## Migration from Environment Variables + +If migrating from environment variables to Secret Manager: + +1. **Create all secrets** in Google Secret Manager +2. **Update deployment configs** to set `NODE_ENV=production` and `GCP_PROJECT` +3. **Remove sensitive env vars** from deployment configs +4. **Keep non-sensitive configs** as env vars (CORS_ORIGIN, PORT, etc.) +5. **Test thoroughly** in staging before production + +## References + +- [Google Secret Manager Documentation](https://cloud.google.com/secret-manager/docs) +- [IAM Roles for Secret Manager](https://cloud.google.com/secret-manager/docs/access-control) +- [@google-cloud/secret-manager NPM](https://www.npmjs.com/package/@google-cloud/secret-manager) + +## Support + +For issues with Secret Manager integration: + +1. Check application logs for detailed error messages +2. Verify IAM permissions in Google Cloud Console +3. Ensure all required secrets exist in Secret Manager +4. Check that service account authentication is working +