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
+