vibecarbon 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +663 -0
- package/README.md +188 -0
- package/carbon/.claude/hooks/task-completed-gate.sh +19 -0
- package/carbon/.claude/hooks/teammate-idle-gate.sh +29 -0
- package/carbon/.claude/settings.json +29 -0
- package/carbon/.cursor/rules/vibecarbon.mdc +6 -0
- package/carbon/.dockerignore +57 -0
- package/carbon/.env.example +219 -0
- package/carbon/.github/copilot-instructions.md +3 -0
- package/carbon/.github/workflows/deploy.yml +346 -0
- package/carbon/.github/workflows/vibecarbon-build.yml +81 -0
- package/carbon/.windsurfrules +74 -0
- package/carbon/AGENTS.md +422 -0
- package/carbon/CLAUDE.md +3 -0
- package/carbon/DEVELOPMENT.md +187 -0
- package/carbon/Dockerfile +98 -0
- package/carbon/LICENSE +21 -0
- package/carbon/PRODUCTION.md +364 -0
- package/carbon/README.md +193 -0
- package/carbon/TESTING.md +138 -0
- package/carbon/backup/Dockerfile +75 -0
- package/carbon/backup/backup.sh +95 -0
- package/carbon/backup/compose-backup.sh +140 -0
- package/carbon/biome.json +83 -0
- package/carbon/cloud-init/docker-ce-setup.yaml +55 -0
- package/carbon/cloud-init/k3s/master-init.sh +215 -0
- package/carbon/cloud-init/k3s/supabase-init.sh +167 -0
- package/carbon/cloud-init/k3s/worker-init.sh +147 -0
- package/carbon/components.json +24 -0
- package/carbon/content/blog/authentication-guide.mdx +34 -0
- package/carbon/content/blog/getting-started.mdx +41 -0
- package/carbon/content/changelog/v0-1-0.mdx +40 -0
- package/carbon/content/docs/analytics.mdx +90 -0
- package/carbon/content/docs/authentication.mdx +231 -0
- package/carbon/content/docs/background-jobs.mdx +116 -0
- package/carbon/content/docs/cli.mdx +630 -0
- package/carbon/content/docs/database.mdx +236 -0
- package/carbon/content/docs/deployment.mdx +227 -0
- package/carbon/content/docs/development.mdx +238 -0
- package/carbon/content/docs/environments.mdx +84 -0
- package/carbon/content/docs/getting-started.mdx +112 -0
- package/carbon/content/docs/legal/privacy-policy.mdx +89 -0
- package/carbon/content/docs/legal/terms-of-service.mdx +99 -0
- package/carbon/content/docs/optional-services.mdx +160 -0
- package/carbon/db/Dockerfile +23 -0
- package/carbon/docker-compose.dev-init.yml +5 -0
- package/carbon/docker-compose.metabase.override.yml +14 -0
- package/carbon/docker-compose.metabase.prod.yml +28 -0
- package/carbon/docker-compose.metabase.yml +84 -0
- package/carbon/docker-compose.n8n.override.yml +14 -0
- package/carbon/docker-compose.n8n.prod.yml +31 -0
- package/carbon/docker-compose.n8n.yml +97 -0
- package/carbon/docker-compose.observability.override.yml +18 -0
- package/carbon/docker-compose.observability.prod.yml +28 -0
- package/carbon/docker-compose.observability.yml +125 -0
- package/carbon/docker-compose.override.yml +28 -0
- package/carbon/docker-compose.prod.yml +294 -0
- package/carbon/docker-compose.yml +508 -0
- package/carbon/docker-entrypoint.sh +12 -0
- package/carbon/ha/activate-standby.sh +52 -0
- package/carbon/ha/primary-init.sql +36 -0
- package/carbon/ha/standby-init.sh +74 -0
- package/carbon/k8s/LICENSE +99 -0
- package/carbon/k8s/README.md +272 -0
- package/carbon/k8s/base/app/deployment.yaml +130 -0
- package/carbon/k8s/base/app/hpa.yaml +44 -0
- package/carbon/k8s/base/app/kustomization.yaml +10 -0
- package/carbon/k8s/base/app/network-policy.yaml +124 -0
- package/carbon/k8s/base/app/pdb.yaml +14 -0
- package/carbon/k8s/base/app/rbac.yaml +36 -0
- package/carbon/k8s/base/app/service.yaml +16 -0
- package/carbon/k8s/base/backup/cronjob.yaml +120 -0
- package/carbon/k8s/base/backup/kustomization.yaml +7 -0
- package/carbon/k8s/base/backup/network-policy.yaml +31 -0
- package/carbon/k8s/base/backup/rbac.yaml +7 -0
- package/carbon/k8s/base/cluster-autoscaler/deployment.yaml +103 -0
- package/carbon/k8s/base/cluster-autoscaler/kustomization.yaml +17 -0
- package/carbon/k8s/base/cluster-autoscaler/rbac.yaml +109 -0
- package/carbon/k8s/base/config/configmap.yaml +48 -0
- package/carbon/k8s/base/config/kustomization.yaml +8 -0
- package/carbon/k8s/base/hetzner-ccm/kustomization.yaml +9 -0
- package/carbon/k8s/base/hetzner-csi/kustomization.yaml +9 -0
- package/carbon/k8s/base/kustomization.yaml +43 -0
- package/carbon/k8s/base/namespace.yaml +7 -0
- package/carbon/k8s/base/network-policies.yaml +95 -0
- package/carbon/k8s/base/registry/kustomization.yaml +5 -0
- package/carbon/k8s/base/registry/local-registry.yaml +193 -0
- package/carbon/k8s/base/traefik/certificate.yaml +21 -0
- package/carbon/k8s/base/traefik/configmap.yaml +13 -0
- package/carbon/k8s/base/traefik/deployment.yaml +165 -0
- package/carbon/k8s/base/traefik/ingressroute.yaml +141 -0
- package/carbon/k8s/base/traefik/kustomization.yaml +11 -0
- package/carbon/k8s/base/traefik/middleware.yaml +109 -0
- package/carbon/k8s/base/traefik/network-policy.yaml +92 -0
- package/carbon/k8s/base/traefik/service.yaml +38 -0
- package/carbon/k8s/flux/README.md +49 -0
- package/carbon/k8s/flux/clusters/primary/vibecarbon.yaml +128 -0
- package/carbon/k8s/flux/clusters/standby/vibecarbon.yaml +83 -0
- package/carbon/k8s/gitops/cert-manager-webhook-hetzner/helm-release.yaml +38 -0
- package/carbon/k8s/gitops/cert-manager-webhook-hetzner/helm-repository.yaml +16 -0
- package/carbon/k8s/gitops/cert-manager-webhook-hetzner/kustomization.yaml +10 -0
- package/carbon/k8s/gitops/supabase/helm-release.yaml +66 -0
- package/carbon/k8s/gitops/supabase/helm-repository.yaml +18 -0
- package/carbon/k8s/gitops/supabase/kustomization.yaml +33 -0
- package/carbon/k8s/infra/cert-manager-resources/cluster-issuers-cloudflare.yaml +51 -0
- package/carbon/k8s/infra/cert-manager-resources/cluster-issuers-hetzner.yaml +59 -0
- package/carbon/k8s/infra/cert-manager-resources/cluster-issuers-manual.yaml +43 -0
- package/carbon/k8s/infra/cert-manager-resources/kustomization.yaml +15 -0
- package/carbon/k8s/infra/kustomization.yaml +21 -0
- package/carbon/k8s/infra/traefik-crds/kustomization.yaml +10 -0
- package/carbon/k8s/overlays/local/configmap.yaml +23 -0
- package/carbon/k8s/overlays/local/ingressroute-studio.yaml +104 -0
- package/carbon/k8s/overlays/local/kustomization.yaml +215 -0
- package/carbon/k8s/overlays/local/namespace.yaml +8 -0
- package/carbon/k8s/overlays/local/secrets.yaml +32 -0
- package/carbon/k8s/overlays/production/kustomization.yaml +34 -0
- package/carbon/k8s/test-local.sh +318 -0
- package/carbon/k8s/values/supabase.values.yaml +133 -0
- package/carbon/package.json +154 -0
- package/carbon/runtime.Dockerfile +17 -0
- package/carbon/scripts/_dev-jwt.mjs +45 -0
- package/carbon/scripts/check-rls.ts +53 -0
- package/carbon/scripts/dev-init.js +191 -0
- package/carbon/scripts/dev.js +191 -0
- package/carbon/scripts/docker-down.js +63 -0
- package/carbon/scripts/docker-logs.js +59 -0
- package/carbon/scripts/docker-up.js +222 -0
- package/carbon/scripts/generate-dev-configs.sh +131 -0
- package/carbon/scripts/generate-rss.ts +116 -0
- package/carbon/scripts/generate-sitemap.ts +102 -0
- package/carbon/scripts/k8s-apply.js +71 -0
- package/carbon/scripts/k8s-delete.js +75 -0
- package/carbon/scripts/lib/manifest.js +176 -0
- package/carbon/scripts/secret-scan.mjs +278 -0
- package/carbon/scripts/validate-dev-configs.sh +101 -0
- package/carbon/src/client/App.tsx +202 -0
- package/carbon/src/client/assets/hyperformant-dark.svg +29 -0
- package/carbon/src/client/assets/hyperformant-light.svg +29 -0
- package/carbon/src/client/assets/logos/aider.svg +1 -0
- package/carbon/src/client/assets/logos/antigravity.svg +1 -0
- package/carbon/src/client/assets/logos/bolt.svg +1 -0
- package/carbon/src/client/assets/logos/claude-code.svg +1 -0
- package/carbon/src/client/assets/logos/copilot.svg +1 -0
- package/carbon/src/client/assets/logos/cursor.svg +1 -0
- package/carbon/src/client/assets/logos/gemini-cli.svg +1 -0
- package/carbon/src/client/assets/logos/lovable.svg +1 -0
- package/carbon/src/client/assets/logos/openai-codex.svg +1 -0
- package/carbon/src/client/assets/logos/v0.svg +1 -0
- package/carbon/src/client/assets/logos/windsurf.svg +1 -0
- package/carbon/src/client/assets/vibecarbon-icon.svg +19 -0
- package/carbon/src/client/assets/vibecarbon-logo-dark.svg +29 -0
- package/carbon/src/client/assets/vibecarbon-logo-light.svg +29 -0
- package/carbon/src/client/components/AIIntegrationSection.tsx +120 -0
- package/carbon/src/client/components/AppSidebar.tsx +760 -0
- package/carbon/src/client/components/ArchitectureSection.tsx +46 -0
- package/carbon/src/client/components/CTAFooter.tsx +59 -0
- package/carbon/src/client/components/ComparisonSection.tsx +132 -0
- package/carbon/src/client/components/ContentPanel.tsx +66 -0
- package/carbon/src/client/components/ContentSkeleton.tsx +21 -0
- package/carbon/src/client/components/ErrorBoundary.tsx +46 -0
- package/carbon/src/client/components/FAQSection.tsx +76 -0
- package/carbon/src/client/components/FileUpload.tsx +210 -0
- package/carbon/src/client/components/HeaderActions.tsx +17 -0
- package/carbon/src/client/components/Hero.tsx +608 -0
- package/carbon/src/client/components/ImpersonationBanner.tsx +31 -0
- package/carbon/src/client/components/LanguageSwitcher.tsx +67 -0
- package/carbon/src/client/components/Logo.tsx +87 -0
- package/carbon/src/client/components/LogoStrip.tsx +76 -0
- package/carbon/src/client/components/MetricsStrip.tsx +48 -0
- package/carbon/src/client/components/Nav.tsx +195 -0
- package/carbon/src/client/components/NewsletterSignup.tsx +147 -0
- package/carbon/src/client/components/NotificationDrawer.tsx +171 -0
- package/carbon/src/client/components/PageHeader.tsx +24 -0
- package/carbon/src/client/components/PlanGate.tsx +36 -0
- package/carbon/src/client/components/PricingSection.tsx +371 -0
- package/carbon/src/client/components/SEO.tsx +34 -0
- package/carbon/src/client/components/SmoothScroll.tsx +45 -0
- package/carbon/src/client/components/TechStackSection.tsx +165 -0
- package/carbon/src/client/components/WorkflowSection.tsx +604 -0
- package/carbon/src/client/components/admin/DockerLogs.tsx +276 -0
- package/carbon/src/client/components/admin/PerformanceMetrics.tsx +230 -0
- package/carbon/src/client/components/admin/ServiceCard.tsx +45 -0
- package/carbon/src/client/components/admin/ServicesStatus.tsx +296 -0
- package/carbon/src/client/components/architecture/ArchitectureDiagram.tsx +413 -0
- package/carbon/src/client/components/auth/AuthProvider.tsx +466 -0
- package/carbon/src/client/components/effects/FilmGrainOverlay.tsx +35 -0
- package/carbon/src/client/components/effects/FresnelEdge.tsx +49 -0
- package/carbon/src/client/components/effects/GlowTracker.tsx +46 -0
- package/carbon/src/client/components/effects/SparkBurst.tsx +145 -0
- package/carbon/src/client/components/effects/VGlowEffect.tsx +83 -0
- package/carbon/src/client/components/effects/index.ts +2 -0
- package/carbon/src/client/components/layouts/SidebarLayout.tsx +20 -0
- package/carbon/src/client/components/scroll/ScrollSection.tsx +76 -0
- package/carbon/src/client/components/scroll/ScrollytellingProvider.tsx +81 -0
- package/carbon/src/client/components/scroll/index.ts +2 -0
- package/carbon/src/client/components/ui/accordion.tsx +71 -0
- package/carbon/src/client/components/ui/alert-dialog.tsx +162 -0
- package/carbon/src/client/components/ui/alert.tsx +73 -0
- package/carbon/src/client/components/ui/aspect-ratio.tsx +22 -0
- package/carbon/src/client/components/ui/avatar.tsx +91 -0
- package/carbon/src/client/components/ui/badge.tsx +50 -0
- package/carbon/src/client/components/ui/breadcrumb.tsx +100 -0
- package/carbon/src/client/components/ui/button-group.tsx +78 -0
- package/carbon/src/client/components/ui/button.tsx +120 -0
- package/carbon/src/client/components/ui/calendar.tsx +182 -0
- package/carbon/src/client/components/ui/card.tsx +85 -0
- package/carbon/src/client/components/ui/carousel.tsx +227 -0
- package/carbon/src/client/components/ui/chart.tsx +357 -0
- package/carbon/src/client/components/ui/checkbox.tsx +27 -0
- package/carbon/src/client/components/ui/collapsible.tsx +15 -0
- package/carbon/src/client/components/ui/command.tsx +178 -0
- package/carbon/src/client/components/ui/context-menu.tsx +233 -0
- package/carbon/src/client/components/ui/dialog.tsx +132 -0
- package/carbon/src/client/components/ui/drawer.tsx +118 -0
- package/carbon/src/client/components/ui/dropdown-menu.tsx +242 -0
- package/carbon/src/client/components/ui/empty.tsx +94 -0
- package/carbon/src/client/components/ui/field.tsx +226 -0
- package/carbon/src/client/components/ui/hover-card.tsx +44 -0
- package/carbon/src/client/components/ui/input-group.tsx +146 -0
- package/carbon/src/client/components/ui/input-otp.tsx +83 -0
- package/carbon/src/client/components/ui/input.tsx +20 -0
- package/carbon/src/client/components/ui/item.tsx +188 -0
- package/carbon/src/client/components/ui/kbd.tsx +26 -0
- package/carbon/src/client/components/ui/label.tsx +21 -0
- package/carbon/src/client/components/ui/menubar.tsx +250 -0
- package/carbon/src/client/components/ui/navigation-menu.tsx +155 -0
- package/carbon/src/client/components/ui/pagination.tsx +102 -0
- package/carbon/src/client/components/ui/popover.tsx +75 -0
- package/carbon/src/client/components/ui/progress.tsx +66 -0
- package/carbon/src/client/components/ui/radio-group.tsx +36 -0
- package/carbon/src/client/components/ui/resizable.tsx +46 -0
- package/carbon/src/client/components/ui/scroll-area.tsx +48 -0
- package/carbon/src/client/components/ui/select.tsx +186 -0
- package/carbon/src/client/components/ui/separator.tsx +21 -0
- package/carbon/src/client/components/ui/sheet.tsx +124 -0
- package/carbon/src/client/components/ui/sidebar.tsx +702 -0
- package/carbon/src/client/components/ui/skeleton.tsx +13 -0
- package/carbon/src/client/components/ui/slider.tsx +57 -0
- package/carbon/src/client/components/ui/sonner.tsx +45 -0
- package/carbon/src/client/components/ui/spinner.tsx +15 -0
- package/carbon/src/client/components/ui/switch.tsx +30 -0
- package/carbon/src/client/components/ui/table.tsx +89 -0
- package/carbon/src/client/components/ui/tabs.tsx +73 -0
- package/carbon/src/client/components/ui/textarea.tsx +18 -0
- package/carbon/src/client/components/ui/toggle-group.tsx +87 -0
- package/carbon/src/client/components/ui/toggle.tsx +44 -0
- package/carbon/src/client/components/ui/tooltip.tsx +56 -0
- package/carbon/src/client/hooks/api/index.ts +14 -0
- package/carbon/src/client/hooks/api/useAuthSettings.ts +80 -0
- package/carbon/src/client/hooks/api/useSubscription.ts +87 -0
- package/carbon/src/client/hooks/use-mobile.ts +21 -0
- package/carbon/src/client/hooks/useMousePosition.ts +91 -0
- package/carbon/src/client/hooks/useNotifications.tsx +124 -0
- package/carbon/src/client/hooks/useOrganizationMembers.ts +127 -0
- package/carbon/src/client/hooks/useOrganizations.tsx +230 -0
- package/carbon/src/client/hooks/usePackageManager.ts +69 -0
- package/carbon/src/client/hooks/useReducedMotion.ts +20 -0
- package/carbon/src/client/hooks/useRunningServices.ts +114 -0
- package/carbon/src/client/hooks/useScrollProgress.ts +56 -0
- package/carbon/src/client/index.css +467 -0
- package/carbon/src/client/index.html +56 -0
- package/carbon/src/client/lib/admin-services.ts +151 -0
- package/carbon/src/client/lib/api.ts +32 -0
- package/carbon/src/client/lib/blog.ts +35 -0
- package/carbon/src/client/lib/changelog.ts +33 -0
- package/carbon/src/client/lib/docs-search.ts +101 -0
- package/carbon/src/client/lib/docs.ts +37 -0
- package/carbon/src/client/lib/i18n.ts +32 -0
- package/carbon/src/client/lib/supabase.ts +72 -0
- package/carbon/src/client/lib/tailwind-colors.ts +357 -0
- package/carbon/src/client/lib/theme.ts +117 -0
- package/carbon/src/client/lib/utils.ts +22 -0
- package/carbon/src/client/locales/de.json +529 -0
- package/carbon/src/client/locales/en.json +461 -0
- package/carbon/src/client/locales/es.json +529 -0
- package/carbon/src/client/locales/fr.json +529 -0
- package/carbon/src/client/locales/pt.json +529 -0
- package/carbon/src/client/main.tsx +56 -0
- package/carbon/src/client/mdx.d.ts +13 -0
- package/carbon/src/client/pages/ApiDocs.tsx +76 -0
- package/carbon/src/client/pages/AuthCallback.tsx +34 -0
- package/carbon/src/client/pages/Blog.tsx +167 -0
- package/carbon/src/client/pages/Changelog.tsx +171 -0
- package/carbon/src/client/pages/Charts.tsx +388 -0
- package/carbon/src/client/pages/Checkout.tsx +227 -0
- package/carbon/src/client/pages/Contact.tsx +174 -0
- package/carbon/src/client/pages/Dashboard.tsx +368 -0
- package/carbon/src/client/pages/Docs.tsx +372 -0
- package/carbon/src/client/pages/ForgotPassword.tsx +111 -0
- package/carbon/src/client/pages/Home.tsx +187 -0
- package/carbon/src/client/pages/Legal.tsx +100 -0
- package/carbon/src/client/pages/Login.tsx +408 -0
- package/carbon/src/client/pages/MFAVerify.tsx +156 -0
- package/carbon/src/client/pages/NotFound.tsx +21 -0
- package/carbon/src/client/pages/Onboarding.tsx +246 -0
- package/carbon/src/client/pages/ResetPassword.tsx +200 -0
- package/carbon/src/client/pages/UIComponents.tsx +390 -0
- package/carbon/src/client/pages/admin/ContactSubmissions.tsx +220 -0
- package/carbon/src/client/pages/admin/Dashboard.tsx +24 -0
- package/carbon/src/client/pages/admin/Infrastructure.tsx +257 -0
- package/carbon/src/client/pages/admin/Jobs.tsx +225 -0
- package/carbon/src/client/pages/admin/Logs.tsx +18 -0
- package/carbon/src/client/pages/admin/Newsletter.tsx +300 -0
- package/carbon/src/client/pages/admin/Notifications.tsx +603 -0
- package/carbon/src/client/pages/admin/Organizations.tsx +306 -0
- package/carbon/src/client/pages/admin/Settings.tsx +314 -0
- package/carbon/src/client/pages/admin/Theme.tsx +465 -0
- package/carbon/src/client/pages/admin/Users.tsx +365 -0
- package/carbon/src/client/pages/api-docs.css +156 -0
- package/carbon/src/client/pages/organizations/Details.tsx +200 -0
- package/carbon/src/client/pages/organizations/Members.tsx +402 -0
- package/carbon/src/client/pages/settings/Billing.tsx +473 -0
- package/carbon/src/client/pages/settings/Profile.tsx +160 -0
- package/carbon/src/client/pages/settings/Security.tsx +341 -0
- package/carbon/src/client/public/favicon.svg +19 -0
- package/carbon/src/client/public/robots.txt +8 -0
- package/carbon/src/server/billing/index.ts +104 -0
- package/carbon/src/server/billing/provider.ts +81 -0
- package/carbon/src/server/billing/providers/paddle.ts +314 -0
- package/carbon/src/server/billing/providers/polar.ts +325 -0
- package/carbon/src/server/billing/providers/stripe.ts +233 -0
- package/carbon/src/server/emails/templates.ts +116 -0
- package/carbon/src/server/index.ts +554 -0
- package/carbon/src/server/lib/auth.ts +6 -0
- package/carbon/src/server/lib/email.ts +64 -0
- package/carbon/src/server/lib/env.ts +112 -0
- package/carbon/src/server/lib/errors.ts +21 -0
- package/carbon/src/server/lib/logger.ts +17 -0
- package/carbon/src/server/lib/rate-limiter.ts +288 -0
- package/carbon/src/server/lib/request.ts +34 -0
- package/carbon/src/server/lib/stripe.ts +42 -0
- package/carbon/src/server/lib/supabase.ts +65 -0
- package/carbon/src/server/middleware/requirePlan.ts +80 -0
- package/carbon/src/server/routes/_internal/services-status.ts +958 -0
- package/carbon/src/server/routes/_internal/verify-role.ts +185 -0
- package/carbon/src/server/routes/health.ts +48 -0
- package/carbon/src/server/routes/v1/admin/contact.ts +128 -0
- package/carbon/src/server/routes/v1/admin/jobs.ts +171 -0
- package/carbon/src/server/routes/v1/admin/newsletter.ts +237 -0
- package/carbon/src/server/routes/v1/auth.ts +390 -0
- package/carbon/src/server/routes/v1/billing.ts +718 -0
- package/carbon/src/server/routes/v1/contact.ts +93 -0
- package/carbon/src/server/routes/v1/index.ts +1333 -0
- package/carbon/src/server/routes/v1/newsletter.ts +181 -0
- package/carbon/src/server/routes/v1/performance.ts +157 -0
- package/carbon/src/server/routes/v1/stats.ts +170 -0
- package/carbon/src/server/routes/v1/theme.ts +106 -0
- package/carbon/src/server/routes/webhooks/billing.ts +376 -0
- package/carbon/src/server/routes/webhooks/stripe.ts +276 -0
- package/carbon/src/server/types.ts +11 -0
- package/carbon/src/shared/pricing.ts +155 -0
- package/carbon/src/shared/types.ts +338 -0
- package/carbon/supabase/migrations/00001_init.sql +717 -0
- package/carbon/supabase/migrations/00002_theme_settings.sql +13 -0
- package/carbon/supabase/migrations/00003_pg_cron.sql +121 -0
- package/carbon/supabase/migrations/00004_contact_newsletter.sql +81 -0
- package/carbon/supabase/migrations/00005_localization_languages.sql +22 -0
- package/carbon/supabase/seed.sql +16 -0
- package/carbon/tests/_helpers/app.ts +45 -0
- package/carbon/tests/_helpers/env.ts +37 -0
- package/carbon/tests/_helpers/factories.ts +69 -0
- package/carbon/tests/_helpers/jwt.ts +23 -0
- package/carbon/tests/_helpers/setup-integration.ts +20 -0
- package/carbon/tests/_helpers/setup-rtl.ts +12 -0
- package/carbon/tests/component/ErrorBoundary.test.tsx +53 -0
- package/carbon/tests/component/use-auth-settings.test.tsx +119 -0
- package/carbon/tests/integration/server/routes/contact.test.ts +162 -0
- package/carbon/tests/integration/server/routes/health.test.ts +61 -0
- package/carbon/tests/structural/i18n-parity.test.ts +42 -0
- package/carbon/tests/unit/client/utils.test.ts +49 -0
- package/carbon/tests/unit/shared/pricing.test.ts +93 -0
- package/carbon/tsconfig.json +27 -0
- package/carbon/tsconfig.server.json +27 -0
- package/carbon/tsconfig.test.json +9 -0
- package/carbon/vite.config.ts +110 -0
- package/carbon/vitest.config.ts +74 -0
- package/carbon/volumes/db/jwt.sql +57 -0
- package/carbon/volumes/db/metabase-init.sh +29 -0
- package/carbon/volumes/db/n8n-init.sh +25 -0
- package/carbon/volumes/db/realtime.sql +33 -0
- package/carbon/volumes/db/roles.sql +93 -0
- package/carbon/volumes/db/set-passwords.sh +12 -0
- package/carbon/volumes/db/super-admin.dev.sql +113 -0
- package/carbon/volumes/db/super-admin.generated.sql +113 -0
- package/carbon/volumes/db/super-admin.sql +114 -0
- package/carbon/volumes/grafana/dashboards/logs.json +179 -0
- package/carbon/volumes/grafana/dashboards/overview.json +523 -0
- package/carbon/volumes/grafana/dashboards/postgresql.json +337 -0
- package/carbon/volumes/grafana/dashboards.dev/logs.json +179 -0
- package/carbon/volumes/grafana/dashboards.dev/overview.json +156 -0
- package/carbon/volumes/grafana/dashboards.dev/postgresql.json +337 -0
- package/carbon/volumes/grafana/provisioning/dashboards/dashboards.dev.yml +16 -0
- package/carbon/volumes/grafana/provisioning/dashboards/dashboards.yml +16 -0
- package/carbon/volumes/grafana/provisioning/datasources/datasources.yml +43 -0
- package/carbon/volumes/kong/docker-entrypoint.sh +9 -0
- package/carbon/volumes/kong/kong.yml +208 -0
- package/carbon/volumes/loki/loki-config.yml +58 -0
- package/carbon/volumes/n8n/hooks.js +131 -0
- package/carbon/volumes/n8n/scripts/setup.sh +66 -0
- package/carbon/volumes/prometheus/prometheus.yml +43 -0
- package/carbon/volumes/promtail/promtail-config.yml +64 -0
- package/carbon/volumes/traefik/middlewares.dev.yml +83 -0
- package/carbon/volumes/traefik/middlewares.yml +95 -0
- package/carbon/volumes/traefik/vite-dev.yml +20 -0
- package/package.json +95 -0
- package/src/access.js +354 -0
- package/src/activate.js +187 -0
- package/src/add.js +718 -0
- package/src/backup.js +786 -0
- package/src/cli.js +350 -0
- package/src/configure.js +967 -0
- package/src/console.js +155 -0
- package/src/create.js +1499 -0
- package/src/deploy.js +311 -0
- package/src/destroy.js +2033 -0
- package/src/diagnose.js +735 -0
- package/src/down.js +80 -0
- package/src/failover.js +1032 -0
- package/src/lib/backup-s3.js +179 -0
- package/src/lib/build.js +33 -0
- package/src/lib/checksum.js +28 -0
- package/src/lib/ci-setup.js +666 -0
- package/src/lib/cli/help.js +129 -0
- package/src/lib/cli/parse-flags.js +160 -0
- package/src/lib/cli/select-action.js +65 -0
- package/src/lib/cli/select-environment.js +108 -0
- package/src/lib/cli/tty-guard.js +75 -0
- package/src/lib/cloudflare.js +447 -0
- package/src/lib/colors.js +52 -0
- package/src/lib/command.js +361 -0
- package/src/lib/config.js +359 -0
- package/src/lib/cost.js +103 -0
- package/src/lib/deploy/bundle.js +231 -0
- package/src/lib/deploy/compose/acme-verify.js +121 -0
- package/src/lib/deploy/compose/build-args.js +78 -0
- package/src/lib/deploy/compose/ha.js +1874 -0
- package/src/lib/deploy/compose/index.js +1294 -0
- package/src/lib/deploy/compose/reconcile.js +74 -0
- package/src/lib/deploy/github.js +382 -0
- package/src/lib/deploy/image.js +191 -0
- package/src/lib/deploy/index.js +119 -0
- package/src/lib/deploy/k8s/LICENSE +99 -0
- package/src/lib/deploy/k8s/gitops-deploy.js +298 -0
- package/src/lib/deploy/k8s/ha/index.js +1000 -0
- package/src/lib/deploy/k8s/index.js +62 -0
- package/src/lib/deploy/k8s/k3s.js +2515 -0
- package/src/lib/deploy/orchestrator.js +1401 -0
- package/src/lib/deploy/prompts.js +545 -0
- package/src/lib/deploy/remote-build.js +130 -0
- package/src/lib/deploy/state.js +120 -0
- package/src/lib/deploy/utils.js +328 -0
- package/src/lib/deploy-logger.js +93 -0
- package/src/lib/dns-propagation.js +48 -0
- package/src/lib/environment.js +58 -0
- package/src/lib/fetch-retry.js +103 -0
- package/src/lib/github-environments.js +335 -0
- package/src/lib/hetzner-dns.js +377 -0
- package/src/lib/hetzner-guided-setup.js +275 -0
- package/src/lib/host-keys.js +37 -0
- package/src/lib/iac/cloud-init.js +52 -0
- package/src/lib/iac/index.js +325 -0
- package/src/lib/iac/programs/hetzner-compose.js +130 -0
- package/src/lib/iac/programs/hetzner-k8s.js +320 -0
- package/src/lib/kubectl.js +81 -0
- package/src/lib/licensing/index.js +259 -0
- package/src/lib/licensing/tiers.js +181 -0
- package/src/lib/licensing/validator.js +171 -0
- package/src/lib/merge-package-json.js +90 -0
- package/src/lib/operator-ip.js +381 -0
- package/src/lib/package-manager.js +111 -0
- package/src/lib/perf.js +71 -0
- package/src/lib/project-guard.js +39 -0
- package/src/lib/project.js +334 -0
- package/src/lib/providers/base.js +276 -0
- package/src/lib/providers/hetzner-s3.js +656 -0
- package/src/lib/providers/hetzner.js +755 -0
- package/src/lib/providers/index.js +164 -0
- package/src/lib/s3.js +510 -0
- package/src/lib/secret-scan.js +583 -0
- package/src/lib/secrets.js +63 -0
- package/src/lib/server-types.js +195 -0
- package/src/lib/shell.js +91 -0
- package/src/lib/ssh.js +241 -0
- package/src/lib/tracker.js +242 -0
- package/src/lib/upgrade-policy.js +170 -0
- package/src/lib/validators.js +105 -0
- package/src/lib/version.js +5 -0
- package/src/remove.js +292 -0
- package/src/reset.js +97 -0
- package/src/restore.js +871 -0
- package/src/scale.js +1734 -0
- package/src/shell.js +222 -0
- package/src/status.js +981 -0
- package/src/up.js +264 -0
- package/src/upgrade.js +721 -0
|
@@ -0,0 +1,1333 @@
|
|
|
1
|
+
import type { SupabaseClient } from '@supabase/supabase-js';
|
|
2
|
+
import { Hono } from 'hono';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { orgInviteEmail } from '../../emails/templates';
|
|
5
|
+
import { isSuperAdmin } from '../../lib/auth';
|
|
6
|
+
import { sendEmail } from '../../lib/email';
|
|
7
|
+
import { sanitizeError } from '../../lib/errors';
|
|
8
|
+
import { logger } from '../../lib/logger';
|
|
9
|
+
import { type getSupabaseClient, supabaseAdmin } from '../../lib/supabase';
|
|
10
|
+
import type { HonoVariables } from '../../types';
|
|
11
|
+
|
|
12
|
+
// Helper to access tables not yet in generated types (notifications, etc.)
|
|
13
|
+
// biome-ignore lint/suspicious/noExplicitAny: Tables not yet in generated Database types
|
|
14
|
+
const adminDb = supabaseAdmin as SupabaseClient<any>;
|
|
15
|
+
|
|
16
|
+
const v1Routes = new Hono<{ Variables: HonoVariables }>();
|
|
17
|
+
|
|
18
|
+
// ============================================================================
|
|
19
|
+
// VALIDATION SCHEMAS
|
|
20
|
+
// ============================================================================
|
|
21
|
+
|
|
22
|
+
const createOrganizationSchema = z.object({
|
|
23
|
+
name: z
|
|
24
|
+
.string()
|
|
25
|
+
.min(1, 'Name is required')
|
|
26
|
+
.max(100, 'Name must be 100 characters or less')
|
|
27
|
+
.trim(),
|
|
28
|
+
slug: z
|
|
29
|
+
.string()
|
|
30
|
+
.min(3, 'Slug must be at least 3 characters')
|
|
31
|
+
.max(50, 'Slug must be 50 characters or less')
|
|
32
|
+
.regex(/^[a-z0-9-]+$/, 'Slug can only contain lowercase letters, numbers, and hyphens')
|
|
33
|
+
.trim(),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const addMemberSchema = z.object({
|
|
37
|
+
email: z.string().email('Valid email is required'),
|
|
38
|
+
role: z.enum(['ADMIN', 'MEMBER']).default('MEMBER'),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const updateMemberRoleSchema = z.object({
|
|
42
|
+
role: z.enum(['OWNER', 'ADMIN', 'MEMBER']),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const createNotificationSchema = z.object({
|
|
46
|
+
title: z.string().min(1, 'Title is required').max(200, 'Title must be 200 characters or less'),
|
|
47
|
+
message: z.string().max(1000, 'Message must be 1000 characters or less').optional(),
|
|
48
|
+
type: z.enum(['info', 'warning', 'error', 'success']).default('info'),
|
|
49
|
+
visibility: z.enum(['all', 'authenticated', 'public']).default('all'),
|
|
50
|
+
dismissible: z.boolean().default(true),
|
|
51
|
+
organizationId: z.string().uuid().optional(),
|
|
52
|
+
startsAt: z.string().datetime().optional(),
|
|
53
|
+
endsAt: z.string().datetime().optional(),
|
|
54
|
+
actionLabel: z.string().max(50).optional().or(z.literal('')),
|
|
55
|
+
actionUrl: z.string().url().optional().or(z.literal('')),
|
|
56
|
+
isActive: z.boolean().default(true),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const updateNotificationSchema = createNotificationSchema.partial();
|
|
60
|
+
|
|
61
|
+
// Pagination schema for admin list endpoints - prevents DoS via excessive limits or complex searches
|
|
62
|
+
const adminPaginationSchema = z.object({
|
|
63
|
+
search: z
|
|
64
|
+
.string()
|
|
65
|
+
.max(100, 'Search query too long')
|
|
66
|
+
.regex(/^[a-zA-Z0-9@.\-_ ]*$/, 'Search contains invalid characters')
|
|
67
|
+
.optional()
|
|
68
|
+
.default(''),
|
|
69
|
+
sortBy: z.enum(['name', 'slug', 'plan', 'created_at', 'email']).optional().default('created_at'),
|
|
70
|
+
sortOrder: z.enum(['asc', 'desc']).optional().default('desc'),
|
|
71
|
+
page: z.coerce.number().int().min(1).max(1000).optional().default(1),
|
|
72
|
+
limit: z.coerce.number().int().min(1).max(100).optional().default(20),
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
type OrgRole = 'OWNER' | 'ADMIN' | 'MEMBER';
|
|
76
|
+
|
|
77
|
+
// Helper to get user's role in an organization
|
|
78
|
+
async function getUserOrgRole(
|
|
79
|
+
supabase: ReturnType<typeof getSupabaseClient>,
|
|
80
|
+
userId: string,
|
|
81
|
+
organizationId: string
|
|
82
|
+
): Promise<OrgRole | null> {
|
|
83
|
+
const { data } = await supabase
|
|
84
|
+
.from('memberships')
|
|
85
|
+
.select('role')
|
|
86
|
+
.eq('user_id', userId)
|
|
87
|
+
.eq('organization_id', organizationId)
|
|
88
|
+
.single();
|
|
89
|
+
|
|
90
|
+
return (data?.role as OrgRole) || null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Helper to check if user has admin access (OWNER or ADMIN)
|
|
94
|
+
async function hasOrgAdminAccess(
|
|
95
|
+
supabase: ReturnType<typeof getSupabaseClient>,
|
|
96
|
+
userId: string,
|
|
97
|
+
organizationId: string
|
|
98
|
+
): Promise<boolean> {
|
|
99
|
+
const role = await getUserOrgRole(supabase, userId, organizationId);
|
|
100
|
+
return role === 'OWNER' || role === 'ADMIN';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ============================================================================
|
|
104
|
+
// USER ENDPOINTS
|
|
105
|
+
// ============================================================================
|
|
106
|
+
|
|
107
|
+
// Get current user info
|
|
108
|
+
v1Routes.get('/me', async (c) => {
|
|
109
|
+
const user = c.get('user');
|
|
110
|
+
|
|
111
|
+
if (!user) {
|
|
112
|
+
return c.json({ error: 'Unauthorized' }, 401);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Fetch user's organizations/memberships
|
|
116
|
+
const supabase = c.get('supabase');
|
|
117
|
+
const { data: memberships, error } = await supabase.from('memberships').select(`
|
|
118
|
+
id,
|
|
119
|
+
role,
|
|
120
|
+
organization:organizations (
|
|
121
|
+
id,
|
|
122
|
+
name,
|
|
123
|
+
slug,
|
|
124
|
+
plan,
|
|
125
|
+
created_at,
|
|
126
|
+
updated_at
|
|
127
|
+
)
|
|
128
|
+
`);
|
|
129
|
+
|
|
130
|
+
if (error) {
|
|
131
|
+
return c.json({ error: sanitizeError(error, 'Failed to fetch memberships') }, 500);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return c.json({
|
|
135
|
+
user: {
|
|
136
|
+
id: user.id,
|
|
137
|
+
email: user.email,
|
|
138
|
+
name: user.user_metadata?.full_name || user.user_metadata?.name,
|
|
139
|
+
avatar: user.user_metadata?.avatar_url,
|
|
140
|
+
emailVerified: user.email_confirmed_at !== null,
|
|
141
|
+
role: user.app_metadata?.role || null,
|
|
142
|
+
},
|
|
143
|
+
memberships,
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// Delete current user's account
|
|
148
|
+
v1Routes.delete('/me', async (c) => {
|
|
149
|
+
const user = c.get('user');
|
|
150
|
+
|
|
151
|
+
if (!user) {
|
|
152
|
+
return c.json({ error: 'Unauthorized' }, 401);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Super admins cannot delete themselves
|
|
156
|
+
if (isSuperAdmin(user)) {
|
|
157
|
+
return c.json(
|
|
158
|
+
{ error: 'Super admins cannot delete their own account. Remove the super_admin role first.' },
|
|
159
|
+
403
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Check if user is the sole member of any organization
|
|
164
|
+
const { data: memberships } = await supabaseAdmin
|
|
165
|
+
.from('memberships')
|
|
166
|
+
.select('organization_id')
|
|
167
|
+
.eq('user_id', user.id);
|
|
168
|
+
|
|
169
|
+
if (memberships && memberships.length > 0) {
|
|
170
|
+
for (const membership of memberships) {
|
|
171
|
+
const { count } = await supabaseAdmin
|
|
172
|
+
.from('memberships')
|
|
173
|
+
.select('*', { count: 'exact', head: true })
|
|
174
|
+
.eq('organization_id', membership.organization_id);
|
|
175
|
+
|
|
176
|
+
if (count === 1) {
|
|
177
|
+
const { data: org } = await supabaseAdmin
|
|
178
|
+
.from('organizations')
|
|
179
|
+
.select('name')
|
|
180
|
+
.eq('id', membership.organization_id)
|
|
181
|
+
.single();
|
|
182
|
+
|
|
183
|
+
return c.json(
|
|
184
|
+
{
|
|
185
|
+
error: `You are the only member of "${org?.name || 'an organization'}". Transfer ownership or delete the organization first.`,
|
|
186
|
+
},
|
|
187
|
+
400
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Cancel any active Stripe subscriptions
|
|
194
|
+
const { data: customer } = await adminDb
|
|
195
|
+
.from('customers')
|
|
196
|
+
.select('stripe_customer_id')
|
|
197
|
+
.eq('user_id', user.id)
|
|
198
|
+
.is('organization_id', null)
|
|
199
|
+
.maybeSingle();
|
|
200
|
+
|
|
201
|
+
if (customer?.stripe_customer_id) {
|
|
202
|
+
try {
|
|
203
|
+
const { isStripeConfigured, getStripe } = await import('../../lib/stripe');
|
|
204
|
+
if (isStripeConfigured()) {
|
|
205
|
+
const stripe = getStripe();
|
|
206
|
+
const subscriptions = await stripe.subscriptions.list({
|
|
207
|
+
customer: customer.stripe_customer_id,
|
|
208
|
+
status: 'active',
|
|
209
|
+
});
|
|
210
|
+
for (const sub of subscriptions.data) {
|
|
211
|
+
await stripe.subscriptions.cancel(sub.id);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
} catch (stripeError) {
|
|
215
|
+
logger.warn(
|
|
216
|
+
{ error: stripeError },
|
|
217
|
+
'Failed to cancel Stripe subscriptions during account deletion'
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Delete the user (cascades to memberships, customers, notification_dismissals)
|
|
223
|
+
const { error: deleteError } = await supabaseAdmin.auth.admin.deleteUser(user.id);
|
|
224
|
+
|
|
225
|
+
if (deleteError) {
|
|
226
|
+
return c.json({ error: sanitizeError(deleteError, 'Failed to delete account') }, 500);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
logger.info({ userId: user.id, email: user.email }, 'User account deleted');
|
|
230
|
+
|
|
231
|
+
return c.json({ success: true });
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// ============================================================================
|
|
235
|
+
// ORGANIZATION ENDPOINTS
|
|
236
|
+
// ============================================================================
|
|
237
|
+
|
|
238
|
+
// Create organization
|
|
239
|
+
v1Routes.post('/organizations', async (c) => {
|
|
240
|
+
const user = c.get('user');
|
|
241
|
+
|
|
242
|
+
if (!user) {
|
|
243
|
+
return c.json({ error: 'Unauthorized' }, 401);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Parse and validate request body
|
|
247
|
+
let body: z.infer<typeof createOrganizationSchema>;
|
|
248
|
+
try {
|
|
249
|
+
const rawBody = await c.req.json();
|
|
250
|
+
const result = createOrganizationSchema.safeParse(rawBody);
|
|
251
|
+
|
|
252
|
+
if (!result.success) {
|
|
253
|
+
const errors = result.error.issues.map((e: { message: string }) => e.message).join(', ');
|
|
254
|
+
return c.json({ error: errors }, 400);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
body = result.data;
|
|
258
|
+
} catch {
|
|
259
|
+
return c.json({ error: 'Invalid JSON body' }, 400);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Use admin client to create org (bypasses RLS for insert)
|
|
263
|
+
const { data: org, error: orgError } = await supabaseAdmin
|
|
264
|
+
.from('organizations')
|
|
265
|
+
.insert({ name: body.name, slug: body.slug })
|
|
266
|
+
.select()
|
|
267
|
+
.single();
|
|
268
|
+
|
|
269
|
+
if (orgError) {
|
|
270
|
+
// Check for unique constraint violation
|
|
271
|
+
if (orgError.code === '23505') {
|
|
272
|
+
return c.json({ error: 'An organization with this slug already exists' }, 400);
|
|
273
|
+
}
|
|
274
|
+
return c.json({ error: sanitizeError(orgError, 'Failed to create organization') }, 400);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Create membership for the creator as OWNER
|
|
278
|
+
const { error: memberError } = await supabaseAdmin.from('memberships').insert({
|
|
279
|
+
user_id: user.id,
|
|
280
|
+
organization_id: org.id,
|
|
281
|
+
role: 'OWNER',
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
if (memberError) {
|
|
285
|
+
// Rollback org creation
|
|
286
|
+
await supabaseAdmin.from('organizations').delete().eq('id', org.id);
|
|
287
|
+
return c.json({ error: sanitizeError(memberError, 'Failed to create membership') }, 400);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return c.json({ organization: org }, 201);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// List user's organizations
|
|
294
|
+
v1Routes.get('/organizations', async (c) => {
|
|
295
|
+
const user = c.get('user');
|
|
296
|
+
|
|
297
|
+
if (!user) {
|
|
298
|
+
return c.json({ error: 'Unauthorized' }, 401);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const supabase = c.get('supabase');
|
|
302
|
+
const { data, error } = await supabase.from('organizations').select('*');
|
|
303
|
+
|
|
304
|
+
if (error) {
|
|
305
|
+
return c.json({ error: sanitizeError(error, 'Failed to fetch organizations') }, 500);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return c.json({ organizations: data });
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
// ============================================================================
|
|
312
|
+
// MEMBERSHIP ENDPOINTS
|
|
313
|
+
// ============================================================================
|
|
314
|
+
|
|
315
|
+
// List organization members
|
|
316
|
+
v1Routes.get('/organizations/:orgId/members', async (c) => {
|
|
317
|
+
const user = c.get('user');
|
|
318
|
+
|
|
319
|
+
if (!user) {
|
|
320
|
+
return c.json({ error: 'Unauthorized' }, 401);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const orgId = c.req.param('orgId');
|
|
324
|
+
const supabase = c.get('supabase');
|
|
325
|
+
|
|
326
|
+
// Check if user is a member of this organization (RLS will handle this)
|
|
327
|
+
const { data: members, error } = await supabase
|
|
328
|
+
.from('memberships')
|
|
329
|
+
.select(`
|
|
330
|
+
id,
|
|
331
|
+
role,
|
|
332
|
+
created_at,
|
|
333
|
+
user_id
|
|
334
|
+
`)
|
|
335
|
+
.eq('organization_id', orgId);
|
|
336
|
+
|
|
337
|
+
if (error) {
|
|
338
|
+
return c.json({ error: sanitizeError(error, 'Failed to fetch members') }, 500);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// If no members returned, either org doesn't exist or user isn't a member
|
|
342
|
+
if (!members || members.length === 0) {
|
|
343
|
+
return c.json({ error: 'Organization not found or access denied' }, 404);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Fetch user details only for the specific member IDs (not all users)
|
|
347
|
+
const userDetailsMap = new Map<
|
|
348
|
+
string,
|
|
349
|
+
{ id: string; email?: string; name?: string; avatar?: string }
|
|
350
|
+
>();
|
|
351
|
+
|
|
352
|
+
// Fetch users in parallel - more efficient than loading entire user table
|
|
353
|
+
const userPromises = members.map(async (member) => {
|
|
354
|
+
const { data, error } = await supabaseAdmin.auth.admin.getUserById(member.user_id);
|
|
355
|
+
if (!error && data?.user) {
|
|
356
|
+
userDetailsMap.set(member.user_id, {
|
|
357
|
+
id: data.user.id,
|
|
358
|
+
email: data.user.email,
|
|
359
|
+
name: data.user.user_metadata?.full_name || data.user.user_metadata?.name,
|
|
360
|
+
avatar: data.user.user_metadata?.avatar_url,
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
await Promise.all(userPromises);
|
|
366
|
+
|
|
367
|
+
// Map user details to members
|
|
368
|
+
const membersWithDetails = members.map((member) => ({
|
|
369
|
+
id: member.id,
|
|
370
|
+
role: member.role,
|
|
371
|
+
createdAt: member.created_at,
|
|
372
|
+
user: userDetailsMap.get(member.user_id) || null,
|
|
373
|
+
}));
|
|
374
|
+
|
|
375
|
+
return c.json({ members: membersWithDetails });
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
// Add member to organization
|
|
379
|
+
v1Routes.post('/organizations/:orgId/members', async (c) => {
|
|
380
|
+
const user = c.get('user');
|
|
381
|
+
|
|
382
|
+
if (!user) {
|
|
383
|
+
return c.json({ error: 'Unauthorized' }, 401);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const orgId = c.req.param('orgId');
|
|
387
|
+
const supabase = c.get('supabase');
|
|
388
|
+
|
|
389
|
+
// Check if user has admin access
|
|
390
|
+
if (!(await hasOrgAdminAccess(supabase, user.id, orgId))) {
|
|
391
|
+
return c.json({ error: 'You must be an admin to add members' }, 403);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Parse and validate request body
|
|
395
|
+
let body: z.infer<typeof addMemberSchema>;
|
|
396
|
+
try {
|
|
397
|
+
const rawBody = await c.req.json();
|
|
398
|
+
const result = addMemberSchema.safeParse(rawBody);
|
|
399
|
+
|
|
400
|
+
if (!result.success) {
|
|
401
|
+
const errors = result.error.issues.map((e: { message: string }) => e.message).join(', ');
|
|
402
|
+
return c.json({ error: errors }, 400);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
body = result.data;
|
|
406
|
+
} catch {
|
|
407
|
+
return c.json({ error: 'Invalid JSON body' }, 400);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Find user by email using paginated search (avoids loading all users at once)
|
|
411
|
+
const emailLower = body.email.toLowerCase();
|
|
412
|
+
let targetUser = null;
|
|
413
|
+
let page = 1;
|
|
414
|
+
const perPage = 100;
|
|
415
|
+
const maxPages = 50; // Safety limit: 5000 users max search
|
|
416
|
+
|
|
417
|
+
while (!targetUser && page <= maxPages) {
|
|
418
|
+
const { data: usersPage, error: userLookupError } = await supabaseAdmin.auth.admin.listUsers({
|
|
419
|
+
page,
|
|
420
|
+
perPage,
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
if (userLookupError) {
|
|
424
|
+
return c.json({ error: sanitizeError(userLookupError, 'Failed to look up user') }, 500);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
targetUser = usersPage.users.find((u) => u.email?.toLowerCase() === emailLower);
|
|
428
|
+
|
|
429
|
+
// Stop if we've exhausted the user list
|
|
430
|
+
if (usersPage.users.length < perPage) break;
|
|
431
|
+
page++;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (!targetUser) {
|
|
435
|
+
return c.json({ error: 'User not found. They must sign up first.' }, 404);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Check if user is already a member
|
|
439
|
+
const { data: existingMembership } = await supabaseAdmin
|
|
440
|
+
.from('memberships')
|
|
441
|
+
.select('id')
|
|
442
|
+
.eq('user_id', targetUser.id)
|
|
443
|
+
.eq('organization_id', orgId)
|
|
444
|
+
.single();
|
|
445
|
+
|
|
446
|
+
if (existingMembership) {
|
|
447
|
+
return c.json({ error: 'User is already a member of this organization' }, 400);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Add membership using admin client
|
|
451
|
+
const { data: membership, error: memberError } = await supabaseAdmin
|
|
452
|
+
.from('memberships')
|
|
453
|
+
.insert({
|
|
454
|
+
user_id: targetUser.id,
|
|
455
|
+
organization_id: orgId,
|
|
456
|
+
role: body.role,
|
|
457
|
+
})
|
|
458
|
+
.select()
|
|
459
|
+
.single();
|
|
460
|
+
|
|
461
|
+
if (memberError) {
|
|
462
|
+
return c.json({ error: sanitizeError(memberError, 'Failed to add member') }, 500);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
logger.info(
|
|
466
|
+
{ orgId, targetUserId: targetUser.id, role: body.role, addedBy: user.id },
|
|
467
|
+
'Member added to organization'
|
|
468
|
+
);
|
|
469
|
+
|
|
470
|
+
// Send invite email (fire-and-forget, don't block response)
|
|
471
|
+
const { data: orgData } = await supabaseAdmin
|
|
472
|
+
.from('organizations')
|
|
473
|
+
.select('name')
|
|
474
|
+
.eq('id', orgId)
|
|
475
|
+
.single();
|
|
476
|
+
|
|
477
|
+
if (targetUser.email && orgData) {
|
|
478
|
+
const inviterName =
|
|
479
|
+
user.user_metadata?.full_name || user.user_metadata?.name || user.email || 'A team member';
|
|
480
|
+
const baseUrl = process.env.SITE_URL || 'http://localhost:5173';
|
|
481
|
+
const template = orgInviteEmail({
|
|
482
|
+
inviterName,
|
|
483
|
+
organizationName: orgData.name,
|
|
484
|
+
role: body.role,
|
|
485
|
+
loginUrl: `${baseUrl}/login`,
|
|
486
|
+
});
|
|
487
|
+
sendEmail({ to: targetUser.email, ...template }).catch((err) => {
|
|
488
|
+
logger.error({ error: err }, 'Failed to send org invite email');
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
return c.json(
|
|
493
|
+
{
|
|
494
|
+
member: {
|
|
495
|
+
id: membership.id,
|
|
496
|
+
role: membership.role,
|
|
497
|
+
createdAt: membership.created_at,
|
|
498
|
+
user: {
|
|
499
|
+
id: targetUser.id,
|
|
500
|
+
email: targetUser.email,
|
|
501
|
+
name: targetUser.user_metadata?.full_name || targetUser.user_metadata?.name,
|
|
502
|
+
},
|
|
503
|
+
},
|
|
504
|
+
},
|
|
505
|
+
201
|
|
506
|
+
);
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
// Update member role
|
|
510
|
+
v1Routes.patch('/organizations/:orgId/members/:userId', async (c) => {
|
|
511
|
+
const user = c.get('user');
|
|
512
|
+
|
|
513
|
+
if (!user) {
|
|
514
|
+
return c.json({ error: 'Unauthorized' }, 401);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const orgId = c.req.param('orgId');
|
|
518
|
+
const targetUserId = c.req.param('userId');
|
|
519
|
+
const supabase = c.get('supabase');
|
|
520
|
+
|
|
521
|
+
// Parse and validate request body
|
|
522
|
+
let body: z.infer<typeof updateMemberRoleSchema>;
|
|
523
|
+
try {
|
|
524
|
+
const rawBody = await c.req.json();
|
|
525
|
+
const result = updateMemberRoleSchema.safeParse(rawBody);
|
|
526
|
+
|
|
527
|
+
if (!result.success) {
|
|
528
|
+
const errors = result.error.issues.map((e: { message: string }) => e.message).join(', ');
|
|
529
|
+
return c.json({ error: errors }, 400);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
body = result.data;
|
|
533
|
+
} catch {
|
|
534
|
+
return c.json({ error: 'Invalid JSON body' }, 400);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// Get current user's role
|
|
538
|
+
const currentUserRole = await getUserOrgRole(supabase, user.id, orgId);
|
|
539
|
+
|
|
540
|
+
if (!currentUserRole) {
|
|
541
|
+
return c.json({ error: 'Organization not found or access denied' }, 404);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// Get target user's current role
|
|
545
|
+
const targetUserRole = await getUserOrgRole(supabase, targetUserId, orgId);
|
|
546
|
+
|
|
547
|
+
if (!targetUserRole) {
|
|
548
|
+
return c.json({ error: 'Member not found' }, 404);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// Permission checks:
|
|
552
|
+
// 1. Only OWNER can change roles to/from OWNER
|
|
553
|
+
// 2. OWNER can change any role
|
|
554
|
+
// 3. ADMIN can change MEMBER to ADMIN and vice versa, but not touch OWNER
|
|
555
|
+
|
|
556
|
+
if (body.role === 'OWNER' || targetUserRole === 'OWNER') {
|
|
557
|
+
if (currentUserRole !== 'OWNER') {
|
|
558
|
+
return c.json({ error: 'Only the owner can transfer or modify ownership' }, 403);
|
|
559
|
+
}
|
|
560
|
+
} else if (currentUserRole !== 'OWNER' && currentUserRole !== 'ADMIN') {
|
|
561
|
+
return c.json({ error: 'You must be an admin to change member roles' }, 403);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// Prevent changing own role (except owner transferring ownership)
|
|
565
|
+
if (user.id === targetUserId && body.role !== 'OWNER') {
|
|
566
|
+
return c.json({ error: 'You cannot change your own role' }, 400);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Update the role using admin client
|
|
570
|
+
const { error: updateError } = await supabaseAdmin
|
|
571
|
+
.from('memberships')
|
|
572
|
+
.update({ role: body.role })
|
|
573
|
+
.eq('user_id', targetUserId)
|
|
574
|
+
.eq('organization_id', orgId);
|
|
575
|
+
|
|
576
|
+
if (updateError) {
|
|
577
|
+
return c.json({ error: sanitizeError(updateError, 'Failed to update role') }, 500);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// If transferring ownership, demote current owner to admin
|
|
581
|
+
if (body.role === 'OWNER' && user.id !== targetUserId) {
|
|
582
|
+
await supabaseAdmin
|
|
583
|
+
.from('memberships')
|
|
584
|
+
.update({ role: 'ADMIN' })
|
|
585
|
+
.eq('user_id', user.id)
|
|
586
|
+
.eq('organization_id', orgId);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
logger.info(
|
|
590
|
+
{ orgId, targetUserId, oldRole: targetUserRole, newRole: body.role, changedBy: user.id },
|
|
591
|
+
'Member role updated'
|
|
592
|
+
);
|
|
593
|
+
|
|
594
|
+
return c.json({ success: true, role: body.role });
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
// Remove member from organization
|
|
598
|
+
v1Routes.delete('/organizations/:orgId/members/:userId', async (c) => {
|
|
599
|
+
const user = c.get('user');
|
|
600
|
+
|
|
601
|
+
if (!user) {
|
|
602
|
+
return c.json({ error: 'Unauthorized' }, 401);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
const orgId = c.req.param('orgId');
|
|
606
|
+
const targetUserId = c.req.param('userId');
|
|
607
|
+
const supabase = c.get('supabase');
|
|
608
|
+
|
|
609
|
+
// Get current user's role
|
|
610
|
+
const currentUserRole = await getUserOrgRole(supabase, user.id, orgId);
|
|
611
|
+
|
|
612
|
+
if (!currentUserRole) {
|
|
613
|
+
return c.json({ error: 'Organization not found or access denied' }, 404);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// Get target user's role
|
|
617
|
+
const targetUserRole = await getUserOrgRole(supabase, targetUserId, orgId);
|
|
618
|
+
|
|
619
|
+
if (!targetUserRole) {
|
|
620
|
+
return c.json({ error: 'Member not found' }, 404);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// Permission checks:
|
|
624
|
+
// 1. Users can remove themselves (leave org) - unless they're the only owner
|
|
625
|
+
// 2. OWNER can remove anyone except themselves if they're the only owner
|
|
626
|
+
// 3. ADMIN can remove MEMBER only
|
|
627
|
+
|
|
628
|
+
const isSelf = user.id === targetUserId;
|
|
629
|
+
|
|
630
|
+
if (isSelf) {
|
|
631
|
+
// Check if user is the only owner
|
|
632
|
+
if (targetUserRole === 'OWNER') {
|
|
633
|
+
const { count } = await supabaseAdmin
|
|
634
|
+
.from('memberships')
|
|
635
|
+
.select('*', { count: 'exact', head: true })
|
|
636
|
+
.eq('organization_id', orgId)
|
|
637
|
+
.eq('role', 'OWNER');
|
|
638
|
+
|
|
639
|
+
if (count === 1) {
|
|
640
|
+
return c.json({ error: 'You are the only owner. Transfer ownership before leaving.' }, 400);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
} else {
|
|
644
|
+
// Removing someone else
|
|
645
|
+
if (currentUserRole === 'MEMBER') {
|
|
646
|
+
return c.json({ error: 'You must be an admin to remove members' }, 403);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
if (currentUserRole === 'ADMIN' && targetUserRole !== 'MEMBER') {
|
|
650
|
+
return c.json({ error: 'Admins can only remove members, not other admins or owners' }, 403);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
if (targetUserRole === 'OWNER') {
|
|
654
|
+
return c.json({ error: 'Cannot remove the organization owner' }, 403);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// Remove the membership
|
|
659
|
+
const { error: deleteError } = await supabaseAdmin
|
|
660
|
+
.from('memberships')
|
|
661
|
+
.delete()
|
|
662
|
+
.eq('user_id', targetUserId)
|
|
663
|
+
.eq('organization_id', orgId);
|
|
664
|
+
|
|
665
|
+
if (deleteError) {
|
|
666
|
+
return c.json({ error: sanitizeError(deleteError, 'Failed to remove member') }, 500);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
logger.info(
|
|
670
|
+
{ orgId, targetUserId, removedBy: user.id, wasSelf: isSelf },
|
|
671
|
+
'Member removed from organization'
|
|
672
|
+
);
|
|
673
|
+
|
|
674
|
+
return c.json({ success: true });
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
// Delete an organization (OWNER only)
|
|
678
|
+
v1Routes.delete('/organizations/:orgId', async (c) => {
|
|
679
|
+
const user = c.get('user');
|
|
680
|
+
|
|
681
|
+
if (!user) {
|
|
682
|
+
return c.json({ error: 'Unauthorized' }, 401);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
const orgId = c.req.param('orgId');
|
|
686
|
+
const supabase = c.get('supabase');
|
|
687
|
+
|
|
688
|
+
// Only the owner can delete
|
|
689
|
+
const role = await getUserOrgRole(supabase, user.id, orgId);
|
|
690
|
+
|
|
691
|
+
if (!role) {
|
|
692
|
+
return c.json({ error: 'Organization not found or access denied' }, 404);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
if (role !== 'OWNER') {
|
|
696
|
+
return c.json({ error: 'Only the organization owner can delete the organization' }, 403);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// Cannot delete the user's last organization
|
|
700
|
+
const { count: orgCount } = await supabaseAdmin
|
|
701
|
+
.from('memberships')
|
|
702
|
+
.select('*', { count: 'exact', head: true })
|
|
703
|
+
.eq('user_id', user.id);
|
|
704
|
+
|
|
705
|
+
if (orgCount != null && orgCount <= 1) {
|
|
706
|
+
return c.json(
|
|
707
|
+
{ error: 'You cannot delete your only organization. Create another organization first.' },
|
|
708
|
+
400
|
|
709
|
+
);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// Delete all memberships first, then the organization
|
|
713
|
+
const { error: memberError } = await supabaseAdmin
|
|
714
|
+
.from('memberships')
|
|
715
|
+
.delete()
|
|
716
|
+
.eq('organization_id', orgId);
|
|
717
|
+
|
|
718
|
+
if (memberError) {
|
|
719
|
+
return c.json({ error: sanitizeError(memberError, 'Failed to delete organization') }, 500);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
const { error: orgError } = await supabaseAdmin.from('organizations').delete().eq('id', orgId);
|
|
723
|
+
|
|
724
|
+
if (orgError) {
|
|
725
|
+
return c.json({ error: sanitizeError(orgError, 'Failed to delete organization') }, 500);
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
logger.info({ orgId, deletedBy: user.id }, 'Organization deleted');
|
|
729
|
+
|
|
730
|
+
return c.json({ success: true });
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
// ============================================================================
|
|
734
|
+
// ADMIN DATA ENDPOINTS (Super Admin only)
|
|
735
|
+
// ============================================================================
|
|
736
|
+
|
|
737
|
+
// List all organizations (super admin)
|
|
738
|
+
v1Routes.get('/admin/organizations', async (c) => {
|
|
739
|
+
const user = c.get('user');
|
|
740
|
+
|
|
741
|
+
if (!user) {
|
|
742
|
+
return c.json({ error: 'Unauthorized' }, 401);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
if (!isSuperAdmin(user)) {
|
|
746
|
+
return c.json({ error: 'Super admin access required' }, 403);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// Validate and parse query params
|
|
750
|
+
const queryResult = adminPaginationSchema.safeParse({
|
|
751
|
+
search: c.req.query('search'),
|
|
752
|
+
sortBy: c.req.query('sortBy'),
|
|
753
|
+
sortOrder: c.req.query('sortOrder'),
|
|
754
|
+
page: c.req.query('page'),
|
|
755
|
+
limit: c.req.query('limit'),
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
if (!queryResult.success) {
|
|
759
|
+
const errors = queryResult.error.issues.map((e) => e.message).join(', ');
|
|
760
|
+
return c.json({ error: errors }, 400);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
const { search, sortBy, sortOrder, page, limit } = queryResult.data;
|
|
764
|
+
const offset = (page - 1) * limit;
|
|
765
|
+
|
|
766
|
+
// Build query using admin client (bypasses RLS)
|
|
767
|
+
let query = supabaseAdmin
|
|
768
|
+
.from('organizations')
|
|
769
|
+
.select('*, memberships(count)', { count: 'exact' });
|
|
770
|
+
|
|
771
|
+
// Apply search filter (safe: input validated to [a-zA-Z0-9@.\-_ ] via Zod schema)
|
|
772
|
+
// Note: underscore acts as single-char wildcard in SQL ILIKE - acceptable for admin search
|
|
773
|
+
if (search) {
|
|
774
|
+
query = query.or(`name.ilike.%${search}%,slug.ilike.%${search}%`);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// Apply sorting (validated against allowed values)
|
|
778
|
+
const ascending = sortOrder === 'asc';
|
|
779
|
+
if (sortBy === 'name' || sortBy === 'slug' || sortBy === 'plan' || sortBy === 'created_at') {
|
|
780
|
+
query = query.order(sortBy, { ascending });
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// Apply pagination
|
|
784
|
+
query = query.range(offset, offset + limit - 1);
|
|
785
|
+
|
|
786
|
+
const { data: organizations, error, count } = await query;
|
|
787
|
+
|
|
788
|
+
if (error) {
|
|
789
|
+
return c.json({ error: sanitizeError(error, 'Failed to fetch organizations') }, 500);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
return c.json({
|
|
793
|
+
organizations: (organizations || []).map((org) => ({
|
|
794
|
+
id: org.id,
|
|
795
|
+
name: org.name,
|
|
796
|
+
slug: org.slug,
|
|
797
|
+
plan: org.plan,
|
|
798
|
+
memberCount: Array.isArray(org.memberships) ? org.memberships[0]?.count || 0 : 0,
|
|
799
|
+
createdAt: org.created_at,
|
|
800
|
+
updatedAt: org.updated_at,
|
|
801
|
+
})),
|
|
802
|
+
pagination: {
|
|
803
|
+
page,
|
|
804
|
+
limit,
|
|
805
|
+
total: count || 0,
|
|
806
|
+
totalPages: Math.ceil((count || 0) / limit),
|
|
807
|
+
},
|
|
808
|
+
});
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
// List all users (super admin)
|
|
812
|
+
v1Routes.get('/admin/users', async (c) => {
|
|
813
|
+
const user = c.get('user');
|
|
814
|
+
|
|
815
|
+
if (!user) {
|
|
816
|
+
return c.json({ error: 'Unauthorized' }, 401);
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
if (!isSuperAdmin(user)) {
|
|
820
|
+
return c.json({ error: 'Super admin access required' }, 403);
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// Validate and parse query params
|
|
824
|
+
const queryResult = adminPaginationSchema.safeParse({
|
|
825
|
+
search: c.req.query('search'),
|
|
826
|
+
sortBy: c.req.query('sortBy'),
|
|
827
|
+
sortOrder: c.req.query('sortOrder'),
|
|
828
|
+
page: c.req.query('page'),
|
|
829
|
+
limit: c.req.query('limit'),
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
if (!queryResult.success) {
|
|
833
|
+
const errors = queryResult.error.issues.map((e) => e.message).join(', ');
|
|
834
|
+
return c.json({ error: errors }, 400);
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
const { search, sortBy, sortOrder, page, limit } = queryResult.data;
|
|
838
|
+
|
|
839
|
+
// Fetch all users using admin client
|
|
840
|
+
const { data: usersData, error: usersError } = await supabaseAdmin.auth.admin.listUsers({
|
|
841
|
+
page,
|
|
842
|
+
perPage: limit,
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
if (usersError) {
|
|
846
|
+
return c.json({ error: sanitizeError(usersError, 'Failed to fetch users') }, 500);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
let users = usersData.users;
|
|
850
|
+
|
|
851
|
+
// Apply search filter
|
|
852
|
+
if (search) {
|
|
853
|
+
const searchLower = search.toLowerCase();
|
|
854
|
+
users = users.filter(
|
|
855
|
+
(u) =>
|
|
856
|
+
u.email?.toLowerCase().includes(searchLower) ||
|
|
857
|
+
u.user_metadata?.full_name?.toLowerCase().includes(searchLower) ||
|
|
858
|
+
u.user_metadata?.name?.toLowerCase().includes(searchLower)
|
|
859
|
+
);
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
// Apply sorting
|
|
863
|
+
users.sort((a, b) => {
|
|
864
|
+
let aVal: string | Date | undefined;
|
|
865
|
+
let bVal: string | Date | undefined;
|
|
866
|
+
|
|
867
|
+
switch (sortBy) {
|
|
868
|
+
case 'email':
|
|
869
|
+
aVal = a.email;
|
|
870
|
+
bVal = b.email;
|
|
871
|
+
break;
|
|
872
|
+
case 'name':
|
|
873
|
+
aVal = a.user_metadata?.full_name || a.user_metadata?.name || '';
|
|
874
|
+
bVal = b.user_metadata?.full_name || b.user_metadata?.name || '';
|
|
875
|
+
break;
|
|
876
|
+
default:
|
|
877
|
+
// Default to created_at for any other sortBy values
|
|
878
|
+
aVal = a.created_at;
|
|
879
|
+
bVal = b.created_at;
|
|
880
|
+
break;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
if (!aVal && !bVal) return 0;
|
|
884
|
+
if (!aVal) return sortOrder === 'asc' ? -1 : 1;
|
|
885
|
+
if (!bVal) return sortOrder === 'asc' ? 1 : -1;
|
|
886
|
+
|
|
887
|
+
const comparison = aVal < bVal ? -1 : aVal > bVal ? 1 : 0;
|
|
888
|
+
return sortOrder === 'asc' ? comparison : -comparison;
|
|
889
|
+
});
|
|
890
|
+
|
|
891
|
+
// Get membership counts for each user
|
|
892
|
+
const userIds = users.map((u) => u.id);
|
|
893
|
+
const { data: memberships } = await supabaseAdmin
|
|
894
|
+
.from('memberships')
|
|
895
|
+
.select('user_id, organization_id')
|
|
896
|
+
.in('user_id', userIds);
|
|
897
|
+
|
|
898
|
+
const membershipCounts = new Map<string, number>();
|
|
899
|
+
memberships?.forEach((m) => {
|
|
900
|
+
membershipCounts.set(m.user_id, (membershipCounts.get(m.user_id) || 0) + 1);
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
return c.json({
|
|
904
|
+
users: users.map((u) => ({
|
|
905
|
+
id: u.id,
|
|
906
|
+
email: u.email,
|
|
907
|
+
name: u.user_metadata?.full_name || u.user_metadata?.name || null,
|
|
908
|
+
avatar: u.user_metadata?.avatar_url || null,
|
|
909
|
+
role: u.app_metadata?.role || null,
|
|
910
|
+
emailVerified: u.email_confirmed_at !== null,
|
|
911
|
+
organizationCount: membershipCounts.get(u.id) || 0,
|
|
912
|
+
lastSignIn: u.last_sign_in_at,
|
|
913
|
+
createdAt: u.created_at,
|
|
914
|
+
})),
|
|
915
|
+
pagination: {
|
|
916
|
+
page,
|
|
917
|
+
limit,
|
|
918
|
+
total: usersData.total || users.length,
|
|
919
|
+
totalPages: Math.ceil((usersData.total || users.length) / limit),
|
|
920
|
+
},
|
|
921
|
+
});
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
// ============================================================================
|
|
925
|
+
// ADMIN IMPERSONATION (Super Admin only)
|
|
926
|
+
// ============================================================================
|
|
927
|
+
|
|
928
|
+
v1Routes.post('/admin/impersonate/:userId', async (c) => {
|
|
929
|
+
const user = c.get('user');
|
|
930
|
+
|
|
931
|
+
if (!user) {
|
|
932
|
+
return c.json({ error: 'Unauthorized' }, 401);
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
if (!isSuperAdmin(user)) {
|
|
936
|
+
return c.json({ error: 'Super admin access required' }, 403);
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
const targetUserId = c.req.param('userId');
|
|
940
|
+
|
|
941
|
+
if (targetUserId === user.id) {
|
|
942
|
+
return c.json({ error: 'Cannot impersonate yourself' }, 400);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
const { data: targetUserData, error: userError } =
|
|
946
|
+
await supabaseAdmin.auth.admin.getUserById(targetUserId);
|
|
947
|
+
|
|
948
|
+
if (userError || !targetUserData.user) {
|
|
949
|
+
return c.json({ error: 'User not found' }, 404);
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
const targetUser = targetUserData.user;
|
|
953
|
+
|
|
954
|
+
if (targetUser.app_metadata?.role === 'super_admin') {
|
|
955
|
+
return c.json({ error: 'Cannot impersonate another super admin' }, 403);
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
if (!targetUser.email) {
|
|
959
|
+
return c.json({ error: 'User has no email address' }, 400);
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
const { data: linkData, error: linkError } = await supabaseAdmin.auth.admin.generateLink({
|
|
963
|
+
type: 'magiclink',
|
|
964
|
+
email: targetUser.email,
|
|
965
|
+
});
|
|
966
|
+
|
|
967
|
+
if (linkError || !linkData) {
|
|
968
|
+
logger.error({ error: linkError }, 'Failed to generate impersonation link');
|
|
969
|
+
return c.json({ error: 'Failed to generate impersonation session' }, 500);
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
logger.info(
|
|
973
|
+
{ adminId: user.id, adminEmail: user.email, targetUserId, targetEmail: targetUser.email },
|
|
974
|
+
'Admin impersonation started'
|
|
975
|
+
);
|
|
976
|
+
|
|
977
|
+
return c.json({
|
|
978
|
+
tokenHash: linkData.properties.hashed_token,
|
|
979
|
+
user: {
|
|
980
|
+
id: targetUser.id,
|
|
981
|
+
email: targetUser.email,
|
|
982
|
+
name: targetUser.user_metadata?.full_name || targetUser.user_metadata?.name || null,
|
|
983
|
+
},
|
|
984
|
+
});
|
|
985
|
+
});
|
|
986
|
+
|
|
987
|
+
// ============================================================================
|
|
988
|
+
// NOTIFICATION ENDPOINTS
|
|
989
|
+
// ============================================================================
|
|
990
|
+
|
|
991
|
+
// Get active notifications for current user (or public notifications for unauthenticated visitors)
|
|
992
|
+
v1Routes.get('/notifications', async (c) => {
|
|
993
|
+
const user = c.get('user');
|
|
994
|
+
|
|
995
|
+
if (!user) {
|
|
996
|
+
// Serve public notifications for unauthenticated visitors via admin client with explicit filtering
|
|
997
|
+
const now = new Date().toISOString();
|
|
998
|
+
const { data: notifications, error } = await adminDb
|
|
999
|
+
.from('notifications')
|
|
1000
|
+
.select('*')
|
|
1001
|
+
.eq('is_active', true)
|
|
1002
|
+
.in('visibility', ['all', 'public'])
|
|
1003
|
+
.is('organization_id', null)
|
|
1004
|
+
.or(`starts_at.is.null,starts_at.lte.${now}`)
|
|
1005
|
+
.or(`ends_at.is.null,ends_at.gt.${now}`)
|
|
1006
|
+
.order('created_at', { ascending: false });
|
|
1007
|
+
|
|
1008
|
+
if (error) {
|
|
1009
|
+
return c.json({ error: sanitizeError(error, 'Failed to fetch notifications') }, 500);
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
return c.json({
|
|
1013
|
+
notifications: (notifications || []).map((n) => ({
|
|
1014
|
+
id: n.id,
|
|
1015
|
+
title: n.title,
|
|
1016
|
+
message: n.message,
|
|
1017
|
+
type: n.type,
|
|
1018
|
+
dismissible: n.dismissible,
|
|
1019
|
+
action:
|
|
1020
|
+
n.action_label && n.action_url ? { label: n.action_label, url: n.action_url } : undefined,
|
|
1021
|
+
createdAt: n.created_at,
|
|
1022
|
+
})),
|
|
1023
|
+
});
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
// Cast to any to access tables not yet in generated types
|
|
1027
|
+
// biome-ignore lint/suspicious/noExplicitAny: Tables not yet in generated Database types
|
|
1028
|
+
const supabase = c.get('supabase') as SupabaseClient<any>;
|
|
1029
|
+
|
|
1030
|
+
// Fetch active notifications (RLS handles visibility)
|
|
1031
|
+
const { data: notifications, error } = await supabase
|
|
1032
|
+
.from('notifications')
|
|
1033
|
+
.select('*')
|
|
1034
|
+
.order('created_at', { ascending: false });
|
|
1035
|
+
|
|
1036
|
+
if (error) {
|
|
1037
|
+
return c.json({ error: sanitizeError(error, 'Failed to fetch notifications') }, 500);
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// Fetch user's dismissals
|
|
1041
|
+
const { data: dismissals } = await supabase
|
|
1042
|
+
.from('notification_dismissals')
|
|
1043
|
+
.select('notification_id');
|
|
1044
|
+
|
|
1045
|
+
const dismissedIds = new Set(dismissals?.map((d) => d.notification_id) || []);
|
|
1046
|
+
|
|
1047
|
+
// Filter out dismissed notifications and format response
|
|
1048
|
+
const activeNotifications = (notifications || [])
|
|
1049
|
+
.filter((n) => !dismissedIds.has(n.id))
|
|
1050
|
+
.map((n) => ({
|
|
1051
|
+
id: n.id,
|
|
1052
|
+
title: n.title,
|
|
1053
|
+
message: n.message,
|
|
1054
|
+
type: n.type,
|
|
1055
|
+
dismissible: n.dismissible,
|
|
1056
|
+
action:
|
|
1057
|
+
n.action_label && n.action_url ? { label: n.action_label, url: n.action_url } : undefined,
|
|
1058
|
+
createdAt: n.created_at,
|
|
1059
|
+
}));
|
|
1060
|
+
|
|
1061
|
+
return c.json({ notifications: activeNotifications });
|
|
1062
|
+
});
|
|
1063
|
+
|
|
1064
|
+
// Dismiss a notification
|
|
1065
|
+
v1Routes.post('/notifications/:notificationId/dismiss', async (c) => {
|
|
1066
|
+
const user = c.get('user');
|
|
1067
|
+
|
|
1068
|
+
if (!user) {
|
|
1069
|
+
return c.json({ error: 'Unauthorized' }, 401);
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
const notificationId = c.req.param('notificationId');
|
|
1073
|
+
// biome-ignore lint/suspicious/noExplicitAny: Tables not yet in generated Database types
|
|
1074
|
+
const supabase = c.get('supabase') as SupabaseClient<any>;
|
|
1075
|
+
|
|
1076
|
+
// Check if notification exists and is dismissible
|
|
1077
|
+
const { data: notification, error: notifError } = await supabase
|
|
1078
|
+
.from('notifications')
|
|
1079
|
+
.select('id, dismissible')
|
|
1080
|
+
.eq('id', notificationId)
|
|
1081
|
+
.single();
|
|
1082
|
+
|
|
1083
|
+
if (notifError || !notification) {
|
|
1084
|
+
return c.json({ error: 'Notification not found' }, 404);
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
if (!notification.dismissible) {
|
|
1088
|
+
return c.json({ error: 'This notification cannot be dismissed' }, 400);
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
// Create dismissal record
|
|
1092
|
+
const { error: dismissError } = await supabase
|
|
1093
|
+
.from('notification_dismissals')
|
|
1094
|
+
.insert({ notification_id: notificationId, user_id: user.id });
|
|
1095
|
+
|
|
1096
|
+
if (dismissError) {
|
|
1097
|
+
// Ignore duplicate key errors (already dismissed)
|
|
1098
|
+
if (dismissError.code !== '23505') {
|
|
1099
|
+
return c.json({ error: sanitizeError(dismissError, 'Failed to dismiss notification') }, 500);
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
return c.json({ success: true });
|
|
1104
|
+
});
|
|
1105
|
+
|
|
1106
|
+
// ============================================================================
|
|
1107
|
+
// ADMIN NOTIFICATION ENDPOINTS (Super Admin only)
|
|
1108
|
+
// ============================================================================
|
|
1109
|
+
|
|
1110
|
+
// List all notifications (including inactive) for admin management
|
|
1111
|
+
v1Routes.get('/admin/notifications', async (c) => {
|
|
1112
|
+
const user = c.get('user');
|
|
1113
|
+
|
|
1114
|
+
if (!user) {
|
|
1115
|
+
return c.json({ error: 'Unauthorized' }, 401);
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
if (!isSuperAdmin(user)) {
|
|
1119
|
+
return c.json({ error: 'Super admin access required' }, 403);
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
// Use admin client to see all notifications
|
|
1123
|
+
const { data: notifications, error } = await adminDb
|
|
1124
|
+
.from('notifications')
|
|
1125
|
+
.select('*')
|
|
1126
|
+
.order('created_at', { ascending: false });
|
|
1127
|
+
|
|
1128
|
+
if (error) {
|
|
1129
|
+
return c.json({ error: sanitizeError(error, 'Failed to fetch notifications') }, 500);
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
return c.json({
|
|
1133
|
+
notifications: (notifications || []).map((n) => ({
|
|
1134
|
+
id: n.id,
|
|
1135
|
+
title: n.title,
|
|
1136
|
+
message: n.message,
|
|
1137
|
+
type: n.type,
|
|
1138
|
+
visibility: n.visibility,
|
|
1139
|
+
dismissible: n.dismissible,
|
|
1140
|
+
organizationId: n.organization_id,
|
|
1141
|
+
startsAt: n.starts_at,
|
|
1142
|
+
endsAt: n.ends_at,
|
|
1143
|
+
actionLabel: n.action_label,
|
|
1144
|
+
actionUrl: n.action_url,
|
|
1145
|
+
isActive: n.is_active,
|
|
1146
|
+
createdBy: n.created_by,
|
|
1147
|
+
createdAt: n.created_at,
|
|
1148
|
+
updatedAt: n.updated_at,
|
|
1149
|
+
})),
|
|
1150
|
+
});
|
|
1151
|
+
});
|
|
1152
|
+
|
|
1153
|
+
// Create notification
|
|
1154
|
+
v1Routes.post('/admin/notifications', async (c) => {
|
|
1155
|
+
const user = c.get('user');
|
|
1156
|
+
|
|
1157
|
+
if (!user) {
|
|
1158
|
+
return c.json({ error: 'Unauthorized' }, 401);
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
if (!isSuperAdmin(user)) {
|
|
1162
|
+
return c.json({ error: 'Super admin access required' }, 403);
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
// Parse and validate request body
|
|
1166
|
+
let body: z.infer<typeof createNotificationSchema>;
|
|
1167
|
+
try {
|
|
1168
|
+
const rawBody = await c.req.json();
|
|
1169
|
+
const result = createNotificationSchema.safeParse(rawBody);
|
|
1170
|
+
|
|
1171
|
+
if (!result.success) {
|
|
1172
|
+
const errors = result.error.issues.map((e: { message: string }) => e.message).join(', ');
|
|
1173
|
+
return c.json({ error: errors }, 400);
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
body = result.data;
|
|
1177
|
+
} catch {
|
|
1178
|
+
return c.json({ error: 'Invalid JSON body' }, 400);
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
const { data: notification, error } = await adminDb
|
|
1182
|
+
.from('notifications')
|
|
1183
|
+
.insert({
|
|
1184
|
+
title: body.title,
|
|
1185
|
+
message: body.message,
|
|
1186
|
+
type: body.type,
|
|
1187
|
+
visibility: body.visibility,
|
|
1188
|
+
dismissible: body.dismissible,
|
|
1189
|
+
organization_id: body.organizationId,
|
|
1190
|
+
starts_at: body.startsAt,
|
|
1191
|
+
ends_at: body.endsAt,
|
|
1192
|
+
action_label: body.actionLabel,
|
|
1193
|
+
action_url: body.actionUrl,
|
|
1194
|
+
is_active: body.isActive,
|
|
1195
|
+
created_by: user.id,
|
|
1196
|
+
})
|
|
1197
|
+
.select()
|
|
1198
|
+
.single();
|
|
1199
|
+
|
|
1200
|
+
if (error) {
|
|
1201
|
+
return c.json({ error: sanitizeError(error, 'Failed to create notification') }, 500);
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
logger.info({ notificationId: notification.id, createdBy: user.id }, 'Notification created');
|
|
1205
|
+
|
|
1206
|
+
return c.json(
|
|
1207
|
+
{
|
|
1208
|
+
notification: {
|
|
1209
|
+
id: notification.id,
|
|
1210
|
+
title: notification.title,
|
|
1211
|
+
message: notification.message,
|
|
1212
|
+
type: notification.type,
|
|
1213
|
+
visibility: notification.visibility,
|
|
1214
|
+
dismissible: notification.dismissible,
|
|
1215
|
+
organizationId: notification.organization_id,
|
|
1216
|
+
startsAt: notification.starts_at,
|
|
1217
|
+
endsAt: notification.ends_at,
|
|
1218
|
+
actionLabel: notification.action_label,
|
|
1219
|
+
actionUrl: notification.action_url,
|
|
1220
|
+
isActive: notification.is_active,
|
|
1221
|
+
createdAt: notification.created_at,
|
|
1222
|
+
},
|
|
1223
|
+
},
|
|
1224
|
+
201
|
|
1225
|
+
);
|
|
1226
|
+
});
|
|
1227
|
+
|
|
1228
|
+
// Update notification
|
|
1229
|
+
v1Routes.patch('/admin/notifications/:notificationId', async (c) => {
|
|
1230
|
+
const user = c.get('user');
|
|
1231
|
+
|
|
1232
|
+
if (!user) {
|
|
1233
|
+
return c.json({ error: 'Unauthorized' }, 401);
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
if (!isSuperAdmin(user)) {
|
|
1237
|
+
return c.json({ error: 'Super admin access required' }, 403);
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
const notificationId = c.req.param('notificationId');
|
|
1241
|
+
|
|
1242
|
+
// Parse and validate request body
|
|
1243
|
+
let body: z.infer<typeof updateNotificationSchema>;
|
|
1244
|
+
try {
|
|
1245
|
+
const rawBody = await c.req.json();
|
|
1246
|
+
const result = updateNotificationSchema.safeParse(rawBody);
|
|
1247
|
+
|
|
1248
|
+
if (!result.success) {
|
|
1249
|
+
const errors = result.error.issues.map((e: { message: string }) => e.message).join(', ');
|
|
1250
|
+
return c.json({ error: errors }, 400);
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
body = result.data;
|
|
1254
|
+
} catch {
|
|
1255
|
+
return c.json({ error: 'Invalid JSON body' }, 400);
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
// Build update object with snake_case keys
|
|
1259
|
+
const updateData: Record<string, unknown> = {};
|
|
1260
|
+
if (body.title !== undefined) updateData.title = body.title;
|
|
1261
|
+
if (body.message !== undefined) updateData.message = body.message;
|
|
1262
|
+
if (body.type !== undefined) updateData.type = body.type;
|
|
1263
|
+
if (body.visibility !== undefined) updateData.visibility = body.visibility;
|
|
1264
|
+
if (body.dismissible !== undefined) updateData.dismissible = body.dismissible;
|
|
1265
|
+
if (body.organizationId !== undefined) updateData.organization_id = body.organizationId;
|
|
1266
|
+
if (body.startsAt !== undefined) updateData.starts_at = body.startsAt;
|
|
1267
|
+
if (body.endsAt !== undefined) updateData.ends_at = body.endsAt;
|
|
1268
|
+
if (body.actionLabel !== undefined) updateData.action_label = body.actionLabel;
|
|
1269
|
+
if (body.actionUrl !== undefined) updateData.action_url = body.actionUrl;
|
|
1270
|
+
if (body.isActive !== undefined) updateData.is_active = body.isActive;
|
|
1271
|
+
|
|
1272
|
+
const { data: notification, error } = await adminDb
|
|
1273
|
+
.from('notifications')
|
|
1274
|
+
.update(updateData)
|
|
1275
|
+
.eq('id', notificationId)
|
|
1276
|
+
.select()
|
|
1277
|
+
.single();
|
|
1278
|
+
|
|
1279
|
+
if (error) {
|
|
1280
|
+
return c.json({ error: sanitizeError(error, 'Failed to update notification') }, 500);
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
if (!notification) {
|
|
1284
|
+
return c.json({ error: 'Notification not found' }, 404);
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
logger.info({ notificationId, updatedBy: user.id }, 'Notification updated');
|
|
1288
|
+
|
|
1289
|
+
return c.json({
|
|
1290
|
+
notification: {
|
|
1291
|
+
id: notification.id,
|
|
1292
|
+
title: notification.title,
|
|
1293
|
+
message: notification.message,
|
|
1294
|
+
type: notification.type,
|
|
1295
|
+
visibility: notification.visibility,
|
|
1296
|
+
dismissible: notification.dismissible,
|
|
1297
|
+
organizationId: notification.organization_id,
|
|
1298
|
+
startsAt: notification.starts_at,
|
|
1299
|
+
endsAt: notification.ends_at,
|
|
1300
|
+
actionLabel: notification.action_label,
|
|
1301
|
+
actionUrl: notification.action_url,
|
|
1302
|
+
isActive: notification.is_active,
|
|
1303
|
+
updatedAt: notification.updated_at,
|
|
1304
|
+
},
|
|
1305
|
+
});
|
|
1306
|
+
});
|
|
1307
|
+
|
|
1308
|
+
// Delete notification
|
|
1309
|
+
v1Routes.delete('/admin/notifications/:notificationId', async (c) => {
|
|
1310
|
+
const user = c.get('user');
|
|
1311
|
+
|
|
1312
|
+
if (!user) {
|
|
1313
|
+
return c.json({ error: 'Unauthorized' }, 401);
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
if (!isSuperAdmin(user)) {
|
|
1317
|
+
return c.json({ error: 'Super admin access required' }, 403);
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
const notificationId = c.req.param('notificationId');
|
|
1321
|
+
|
|
1322
|
+
const { error } = await adminDb.from('notifications').delete().eq('id', notificationId);
|
|
1323
|
+
|
|
1324
|
+
if (error) {
|
|
1325
|
+
return c.json({ error: sanitizeError(error, 'Failed to delete notification') }, 500);
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
logger.info({ notificationId, deletedBy: user.id }, 'Notification deleted');
|
|
1329
|
+
|
|
1330
|
+
return c.json({ success: true });
|
|
1331
|
+
});
|
|
1332
|
+
|
|
1333
|
+
export { v1Routes };
|