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.
Files changed (495) hide show
  1. package/LICENSE +663 -0
  2. package/README.md +188 -0
  3. package/carbon/.claude/hooks/task-completed-gate.sh +19 -0
  4. package/carbon/.claude/hooks/teammate-idle-gate.sh +29 -0
  5. package/carbon/.claude/settings.json +29 -0
  6. package/carbon/.cursor/rules/vibecarbon.mdc +6 -0
  7. package/carbon/.dockerignore +57 -0
  8. package/carbon/.env.example +219 -0
  9. package/carbon/.github/copilot-instructions.md +3 -0
  10. package/carbon/.github/workflows/deploy.yml +346 -0
  11. package/carbon/.github/workflows/vibecarbon-build.yml +81 -0
  12. package/carbon/.windsurfrules +74 -0
  13. package/carbon/AGENTS.md +422 -0
  14. package/carbon/CLAUDE.md +3 -0
  15. package/carbon/DEVELOPMENT.md +187 -0
  16. package/carbon/Dockerfile +98 -0
  17. package/carbon/LICENSE +21 -0
  18. package/carbon/PRODUCTION.md +364 -0
  19. package/carbon/README.md +193 -0
  20. package/carbon/TESTING.md +138 -0
  21. package/carbon/backup/Dockerfile +75 -0
  22. package/carbon/backup/backup.sh +95 -0
  23. package/carbon/backup/compose-backup.sh +140 -0
  24. package/carbon/biome.json +83 -0
  25. package/carbon/cloud-init/docker-ce-setup.yaml +55 -0
  26. package/carbon/cloud-init/k3s/master-init.sh +215 -0
  27. package/carbon/cloud-init/k3s/supabase-init.sh +167 -0
  28. package/carbon/cloud-init/k3s/worker-init.sh +147 -0
  29. package/carbon/components.json +24 -0
  30. package/carbon/content/blog/authentication-guide.mdx +34 -0
  31. package/carbon/content/blog/getting-started.mdx +41 -0
  32. package/carbon/content/changelog/v0-1-0.mdx +40 -0
  33. package/carbon/content/docs/analytics.mdx +90 -0
  34. package/carbon/content/docs/authentication.mdx +231 -0
  35. package/carbon/content/docs/background-jobs.mdx +116 -0
  36. package/carbon/content/docs/cli.mdx +630 -0
  37. package/carbon/content/docs/database.mdx +236 -0
  38. package/carbon/content/docs/deployment.mdx +227 -0
  39. package/carbon/content/docs/development.mdx +238 -0
  40. package/carbon/content/docs/environments.mdx +84 -0
  41. package/carbon/content/docs/getting-started.mdx +112 -0
  42. package/carbon/content/docs/legal/privacy-policy.mdx +89 -0
  43. package/carbon/content/docs/legal/terms-of-service.mdx +99 -0
  44. package/carbon/content/docs/optional-services.mdx +160 -0
  45. package/carbon/db/Dockerfile +23 -0
  46. package/carbon/docker-compose.dev-init.yml +5 -0
  47. package/carbon/docker-compose.metabase.override.yml +14 -0
  48. package/carbon/docker-compose.metabase.prod.yml +28 -0
  49. package/carbon/docker-compose.metabase.yml +84 -0
  50. package/carbon/docker-compose.n8n.override.yml +14 -0
  51. package/carbon/docker-compose.n8n.prod.yml +31 -0
  52. package/carbon/docker-compose.n8n.yml +97 -0
  53. package/carbon/docker-compose.observability.override.yml +18 -0
  54. package/carbon/docker-compose.observability.prod.yml +28 -0
  55. package/carbon/docker-compose.observability.yml +125 -0
  56. package/carbon/docker-compose.override.yml +28 -0
  57. package/carbon/docker-compose.prod.yml +294 -0
  58. package/carbon/docker-compose.yml +508 -0
  59. package/carbon/docker-entrypoint.sh +12 -0
  60. package/carbon/ha/activate-standby.sh +52 -0
  61. package/carbon/ha/primary-init.sql +36 -0
  62. package/carbon/ha/standby-init.sh +74 -0
  63. package/carbon/k8s/LICENSE +99 -0
  64. package/carbon/k8s/README.md +272 -0
  65. package/carbon/k8s/base/app/deployment.yaml +130 -0
  66. package/carbon/k8s/base/app/hpa.yaml +44 -0
  67. package/carbon/k8s/base/app/kustomization.yaml +10 -0
  68. package/carbon/k8s/base/app/network-policy.yaml +124 -0
  69. package/carbon/k8s/base/app/pdb.yaml +14 -0
  70. package/carbon/k8s/base/app/rbac.yaml +36 -0
  71. package/carbon/k8s/base/app/service.yaml +16 -0
  72. package/carbon/k8s/base/backup/cronjob.yaml +120 -0
  73. package/carbon/k8s/base/backup/kustomization.yaml +7 -0
  74. package/carbon/k8s/base/backup/network-policy.yaml +31 -0
  75. package/carbon/k8s/base/backup/rbac.yaml +7 -0
  76. package/carbon/k8s/base/cluster-autoscaler/deployment.yaml +103 -0
  77. package/carbon/k8s/base/cluster-autoscaler/kustomization.yaml +17 -0
  78. package/carbon/k8s/base/cluster-autoscaler/rbac.yaml +109 -0
  79. package/carbon/k8s/base/config/configmap.yaml +48 -0
  80. package/carbon/k8s/base/config/kustomization.yaml +8 -0
  81. package/carbon/k8s/base/hetzner-ccm/kustomization.yaml +9 -0
  82. package/carbon/k8s/base/hetzner-csi/kustomization.yaml +9 -0
  83. package/carbon/k8s/base/kustomization.yaml +43 -0
  84. package/carbon/k8s/base/namespace.yaml +7 -0
  85. package/carbon/k8s/base/network-policies.yaml +95 -0
  86. package/carbon/k8s/base/registry/kustomization.yaml +5 -0
  87. package/carbon/k8s/base/registry/local-registry.yaml +193 -0
  88. package/carbon/k8s/base/traefik/certificate.yaml +21 -0
  89. package/carbon/k8s/base/traefik/configmap.yaml +13 -0
  90. package/carbon/k8s/base/traefik/deployment.yaml +165 -0
  91. package/carbon/k8s/base/traefik/ingressroute.yaml +141 -0
  92. package/carbon/k8s/base/traefik/kustomization.yaml +11 -0
  93. package/carbon/k8s/base/traefik/middleware.yaml +109 -0
  94. package/carbon/k8s/base/traefik/network-policy.yaml +92 -0
  95. package/carbon/k8s/base/traefik/service.yaml +38 -0
  96. package/carbon/k8s/flux/README.md +49 -0
  97. package/carbon/k8s/flux/clusters/primary/vibecarbon.yaml +128 -0
  98. package/carbon/k8s/flux/clusters/standby/vibecarbon.yaml +83 -0
  99. package/carbon/k8s/gitops/cert-manager-webhook-hetzner/helm-release.yaml +38 -0
  100. package/carbon/k8s/gitops/cert-manager-webhook-hetzner/helm-repository.yaml +16 -0
  101. package/carbon/k8s/gitops/cert-manager-webhook-hetzner/kustomization.yaml +10 -0
  102. package/carbon/k8s/gitops/supabase/helm-release.yaml +66 -0
  103. package/carbon/k8s/gitops/supabase/helm-repository.yaml +18 -0
  104. package/carbon/k8s/gitops/supabase/kustomization.yaml +33 -0
  105. package/carbon/k8s/infra/cert-manager-resources/cluster-issuers-cloudflare.yaml +51 -0
  106. package/carbon/k8s/infra/cert-manager-resources/cluster-issuers-hetzner.yaml +59 -0
  107. package/carbon/k8s/infra/cert-manager-resources/cluster-issuers-manual.yaml +43 -0
  108. package/carbon/k8s/infra/cert-manager-resources/kustomization.yaml +15 -0
  109. package/carbon/k8s/infra/kustomization.yaml +21 -0
  110. package/carbon/k8s/infra/traefik-crds/kustomization.yaml +10 -0
  111. package/carbon/k8s/overlays/local/configmap.yaml +23 -0
  112. package/carbon/k8s/overlays/local/ingressroute-studio.yaml +104 -0
  113. package/carbon/k8s/overlays/local/kustomization.yaml +215 -0
  114. package/carbon/k8s/overlays/local/namespace.yaml +8 -0
  115. package/carbon/k8s/overlays/local/secrets.yaml +32 -0
  116. package/carbon/k8s/overlays/production/kustomization.yaml +34 -0
  117. package/carbon/k8s/test-local.sh +318 -0
  118. package/carbon/k8s/values/supabase.values.yaml +133 -0
  119. package/carbon/package.json +154 -0
  120. package/carbon/runtime.Dockerfile +17 -0
  121. package/carbon/scripts/_dev-jwt.mjs +45 -0
  122. package/carbon/scripts/check-rls.ts +53 -0
  123. package/carbon/scripts/dev-init.js +191 -0
  124. package/carbon/scripts/dev.js +191 -0
  125. package/carbon/scripts/docker-down.js +63 -0
  126. package/carbon/scripts/docker-logs.js +59 -0
  127. package/carbon/scripts/docker-up.js +222 -0
  128. package/carbon/scripts/generate-dev-configs.sh +131 -0
  129. package/carbon/scripts/generate-rss.ts +116 -0
  130. package/carbon/scripts/generate-sitemap.ts +102 -0
  131. package/carbon/scripts/k8s-apply.js +71 -0
  132. package/carbon/scripts/k8s-delete.js +75 -0
  133. package/carbon/scripts/lib/manifest.js +176 -0
  134. package/carbon/scripts/secret-scan.mjs +278 -0
  135. package/carbon/scripts/validate-dev-configs.sh +101 -0
  136. package/carbon/src/client/App.tsx +202 -0
  137. package/carbon/src/client/assets/hyperformant-dark.svg +29 -0
  138. package/carbon/src/client/assets/hyperformant-light.svg +29 -0
  139. package/carbon/src/client/assets/logos/aider.svg +1 -0
  140. package/carbon/src/client/assets/logos/antigravity.svg +1 -0
  141. package/carbon/src/client/assets/logos/bolt.svg +1 -0
  142. package/carbon/src/client/assets/logos/claude-code.svg +1 -0
  143. package/carbon/src/client/assets/logos/copilot.svg +1 -0
  144. package/carbon/src/client/assets/logos/cursor.svg +1 -0
  145. package/carbon/src/client/assets/logos/gemini-cli.svg +1 -0
  146. package/carbon/src/client/assets/logos/lovable.svg +1 -0
  147. package/carbon/src/client/assets/logos/openai-codex.svg +1 -0
  148. package/carbon/src/client/assets/logos/v0.svg +1 -0
  149. package/carbon/src/client/assets/logos/windsurf.svg +1 -0
  150. package/carbon/src/client/assets/vibecarbon-icon.svg +19 -0
  151. package/carbon/src/client/assets/vibecarbon-logo-dark.svg +29 -0
  152. package/carbon/src/client/assets/vibecarbon-logo-light.svg +29 -0
  153. package/carbon/src/client/components/AIIntegrationSection.tsx +120 -0
  154. package/carbon/src/client/components/AppSidebar.tsx +760 -0
  155. package/carbon/src/client/components/ArchitectureSection.tsx +46 -0
  156. package/carbon/src/client/components/CTAFooter.tsx +59 -0
  157. package/carbon/src/client/components/ComparisonSection.tsx +132 -0
  158. package/carbon/src/client/components/ContentPanel.tsx +66 -0
  159. package/carbon/src/client/components/ContentSkeleton.tsx +21 -0
  160. package/carbon/src/client/components/ErrorBoundary.tsx +46 -0
  161. package/carbon/src/client/components/FAQSection.tsx +76 -0
  162. package/carbon/src/client/components/FileUpload.tsx +210 -0
  163. package/carbon/src/client/components/HeaderActions.tsx +17 -0
  164. package/carbon/src/client/components/Hero.tsx +608 -0
  165. package/carbon/src/client/components/ImpersonationBanner.tsx +31 -0
  166. package/carbon/src/client/components/LanguageSwitcher.tsx +67 -0
  167. package/carbon/src/client/components/Logo.tsx +87 -0
  168. package/carbon/src/client/components/LogoStrip.tsx +76 -0
  169. package/carbon/src/client/components/MetricsStrip.tsx +48 -0
  170. package/carbon/src/client/components/Nav.tsx +195 -0
  171. package/carbon/src/client/components/NewsletterSignup.tsx +147 -0
  172. package/carbon/src/client/components/NotificationDrawer.tsx +171 -0
  173. package/carbon/src/client/components/PageHeader.tsx +24 -0
  174. package/carbon/src/client/components/PlanGate.tsx +36 -0
  175. package/carbon/src/client/components/PricingSection.tsx +371 -0
  176. package/carbon/src/client/components/SEO.tsx +34 -0
  177. package/carbon/src/client/components/SmoothScroll.tsx +45 -0
  178. package/carbon/src/client/components/TechStackSection.tsx +165 -0
  179. package/carbon/src/client/components/WorkflowSection.tsx +604 -0
  180. package/carbon/src/client/components/admin/DockerLogs.tsx +276 -0
  181. package/carbon/src/client/components/admin/PerformanceMetrics.tsx +230 -0
  182. package/carbon/src/client/components/admin/ServiceCard.tsx +45 -0
  183. package/carbon/src/client/components/admin/ServicesStatus.tsx +296 -0
  184. package/carbon/src/client/components/architecture/ArchitectureDiagram.tsx +413 -0
  185. package/carbon/src/client/components/auth/AuthProvider.tsx +466 -0
  186. package/carbon/src/client/components/effects/FilmGrainOverlay.tsx +35 -0
  187. package/carbon/src/client/components/effects/FresnelEdge.tsx +49 -0
  188. package/carbon/src/client/components/effects/GlowTracker.tsx +46 -0
  189. package/carbon/src/client/components/effects/SparkBurst.tsx +145 -0
  190. package/carbon/src/client/components/effects/VGlowEffect.tsx +83 -0
  191. package/carbon/src/client/components/effects/index.ts +2 -0
  192. package/carbon/src/client/components/layouts/SidebarLayout.tsx +20 -0
  193. package/carbon/src/client/components/scroll/ScrollSection.tsx +76 -0
  194. package/carbon/src/client/components/scroll/ScrollytellingProvider.tsx +81 -0
  195. package/carbon/src/client/components/scroll/index.ts +2 -0
  196. package/carbon/src/client/components/ui/accordion.tsx +71 -0
  197. package/carbon/src/client/components/ui/alert-dialog.tsx +162 -0
  198. package/carbon/src/client/components/ui/alert.tsx +73 -0
  199. package/carbon/src/client/components/ui/aspect-ratio.tsx +22 -0
  200. package/carbon/src/client/components/ui/avatar.tsx +91 -0
  201. package/carbon/src/client/components/ui/badge.tsx +50 -0
  202. package/carbon/src/client/components/ui/breadcrumb.tsx +100 -0
  203. package/carbon/src/client/components/ui/button-group.tsx +78 -0
  204. package/carbon/src/client/components/ui/button.tsx +120 -0
  205. package/carbon/src/client/components/ui/calendar.tsx +182 -0
  206. package/carbon/src/client/components/ui/card.tsx +85 -0
  207. package/carbon/src/client/components/ui/carousel.tsx +227 -0
  208. package/carbon/src/client/components/ui/chart.tsx +357 -0
  209. package/carbon/src/client/components/ui/checkbox.tsx +27 -0
  210. package/carbon/src/client/components/ui/collapsible.tsx +15 -0
  211. package/carbon/src/client/components/ui/command.tsx +178 -0
  212. package/carbon/src/client/components/ui/context-menu.tsx +233 -0
  213. package/carbon/src/client/components/ui/dialog.tsx +132 -0
  214. package/carbon/src/client/components/ui/drawer.tsx +118 -0
  215. package/carbon/src/client/components/ui/dropdown-menu.tsx +242 -0
  216. package/carbon/src/client/components/ui/empty.tsx +94 -0
  217. package/carbon/src/client/components/ui/field.tsx +226 -0
  218. package/carbon/src/client/components/ui/hover-card.tsx +44 -0
  219. package/carbon/src/client/components/ui/input-group.tsx +146 -0
  220. package/carbon/src/client/components/ui/input-otp.tsx +83 -0
  221. package/carbon/src/client/components/ui/input.tsx +20 -0
  222. package/carbon/src/client/components/ui/item.tsx +188 -0
  223. package/carbon/src/client/components/ui/kbd.tsx +26 -0
  224. package/carbon/src/client/components/ui/label.tsx +21 -0
  225. package/carbon/src/client/components/ui/menubar.tsx +250 -0
  226. package/carbon/src/client/components/ui/navigation-menu.tsx +155 -0
  227. package/carbon/src/client/components/ui/pagination.tsx +102 -0
  228. package/carbon/src/client/components/ui/popover.tsx +75 -0
  229. package/carbon/src/client/components/ui/progress.tsx +66 -0
  230. package/carbon/src/client/components/ui/radio-group.tsx +36 -0
  231. package/carbon/src/client/components/ui/resizable.tsx +46 -0
  232. package/carbon/src/client/components/ui/scroll-area.tsx +48 -0
  233. package/carbon/src/client/components/ui/select.tsx +186 -0
  234. package/carbon/src/client/components/ui/separator.tsx +21 -0
  235. package/carbon/src/client/components/ui/sheet.tsx +124 -0
  236. package/carbon/src/client/components/ui/sidebar.tsx +702 -0
  237. package/carbon/src/client/components/ui/skeleton.tsx +13 -0
  238. package/carbon/src/client/components/ui/slider.tsx +57 -0
  239. package/carbon/src/client/components/ui/sonner.tsx +45 -0
  240. package/carbon/src/client/components/ui/spinner.tsx +15 -0
  241. package/carbon/src/client/components/ui/switch.tsx +30 -0
  242. package/carbon/src/client/components/ui/table.tsx +89 -0
  243. package/carbon/src/client/components/ui/tabs.tsx +73 -0
  244. package/carbon/src/client/components/ui/textarea.tsx +18 -0
  245. package/carbon/src/client/components/ui/toggle-group.tsx +87 -0
  246. package/carbon/src/client/components/ui/toggle.tsx +44 -0
  247. package/carbon/src/client/components/ui/tooltip.tsx +56 -0
  248. package/carbon/src/client/hooks/api/index.ts +14 -0
  249. package/carbon/src/client/hooks/api/useAuthSettings.ts +80 -0
  250. package/carbon/src/client/hooks/api/useSubscription.ts +87 -0
  251. package/carbon/src/client/hooks/use-mobile.ts +21 -0
  252. package/carbon/src/client/hooks/useMousePosition.ts +91 -0
  253. package/carbon/src/client/hooks/useNotifications.tsx +124 -0
  254. package/carbon/src/client/hooks/useOrganizationMembers.ts +127 -0
  255. package/carbon/src/client/hooks/useOrganizations.tsx +230 -0
  256. package/carbon/src/client/hooks/usePackageManager.ts +69 -0
  257. package/carbon/src/client/hooks/useReducedMotion.ts +20 -0
  258. package/carbon/src/client/hooks/useRunningServices.ts +114 -0
  259. package/carbon/src/client/hooks/useScrollProgress.ts +56 -0
  260. package/carbon/src/client/index.css +467 -0
  261. package/carbon/src/client/index.html +56 -0
  262. package/carbon/src/client/lib/admin-services.ts +151 -0
  263. package/carbon/src/client/lib/api.ts +32 -0
  264. package/carbon/src/client/lib/blog.ts +35 -0
  265. package/carbon/src/client/lib/changelog.ts +33 -0
  266. package/carbon/src/client/lib/docs-search.ts +101 -0
  267. package/carbon/src/client/lib/docs.ts +37 -0
  268. package/carbon/src/client/lib/i18n.ts +32 -0
  269. package/carbon/src/client/lib/supabase.ts +72 -0
  270. package/carbon/src/client/lib/tailwind-colors.ts +357 -0
  271. package/carbon/src/client/lib/theme.ts +117 -0
  272. package/carbon/src/client/lib/utils.ts +22 -0
  273. package/carbon/src/client/locales/de.json +529 -0
  274. package/carbon/src/client/locales/en.json +461 -0
  275. package/carbon/src/client/locales/es.json +529 -0
  276. package/carbon/src/client/locales/fr.json +529 -0
  277. package/carbon/src/client/locales/pt.json +529 -0
  278. package/carbon/src/client/main.tsx +56 -0
  279. package/carbon/src/client/mdx.d.ts +13 -0
  280. package/carbon/src/client/pages/ApiDocs.tsx +76 -0
  281. package/carbon/src/client/pages/AuthCallback.tsx +34 -0
  282. package/carbon/src/client/pages/Blog.tsx +167 -0
  283. package/carbon/src/client/pages/Changelog.tsx +171 -0
  284. package/carbon/src/client/pages/Charts.tsx +388 -0
  285. package/carbon/src/client/pages/Checkout.tsx +227 -0
  286. package/carbon/src/client/pages/Contact.tsx +174 -0
  287. package/carbon/src/client/pages/Dashboard.tsx +368 -0
  288. package/carbon/src/client/pages/Docs.tsx +372 -0
  289. package/carbon/src/client/pages/ForgotPassword.tsx +111 -0
  290. package/carbon/src/client/pages/Home.tsx +187 -0
  291. package/carbon/src/client/pages/Legal.tsx +100 -0
  292. package/carbon/src/client/pages/Login.tsx +408 -0
  293. package/carbon/src/client/pages/MFAVerify.tsx +156 -0
  294. package/carbon/src/client/pages/NotFound.tsx +21 -0
  295. package/carbon/src/client/pages/Onboarding.tsx +246 -0
  296. package/carbon/src/client/pages/ResetPassword.tsx +200 -0
  297. package/carbon/src/client/pages/UIComponents.tsx +390 -0
  298. package/carbon/src/client/pages/admin/ContactSubmissions.tsx +220 -0
  299. package/carbon/src/client/pages/admin/Dashboard.tsx +24 -0
  300. package/carbon/src/client/pages/admin/Infrastructure.tsx +257 -0
  301. package/carbon/src/client/pages/admin/Jobs.tsx +225 -0
  302. package/carbon/src/client/pages/admin/Logs.tsx +18 -0
  303. package/carbon/src/client/pages/admin/Newsletter.tsx +300 -0
  304. package/carbon/src/client/pages/admin/Notifications.tsx +603 -0
  305. package/carbon/src/client/pages/admin/Organizations.tsx +306 -0
  306. package/carbon/src/client/pages/admin/Settings.tsx +314 -0
  307. package/carbon/src/client/pages/admin/Theme.tsx +465 -0
  308. package/carbon/src/client/pages/admin/Users.tsx +365 -0
  309. package/carbon/src/client/pages/api-docs.css +156 -0
  310. package/carbon/src/client/pages/organizations/Details.tsx +200 -0
  311. package/carbon/src/client/pages/organizations/Members.tsx +402 -0
  312. package/carbon/src/client/pages/settings/Billing.tsx +473 -0
  313. package/carbon/src/client/pages/settings/Profile.tsx +160 -0
  314. package/carbon/src/client/pages/settings/Security.tsx +341 -0
  315. package/carbon/src/client/public/favicon.svg +19 -0
  316. package/carbon/src/client/public/robots.txt +8 -0
  317. package/carbon/src/server/billing/index.ts +104 -0
  318. package/carbon/src/server/billing/provider.ts +81 -0
  319. package/carbon/src/server/billing/providers/paddle.ts +314 -0
  320. package/carbon/src/server/billing/providers/polar.ts +325 -0
  321. package/carbon/src/server/billing/providers/stripe.ts +233 -0
  322. package/carbon/src/server/emails/templates.ts +116 -0
  323. package/carbon/src/server/index.ts +554 -0
  324. package/carbon/src/server/lib/auth.ts +6 -0
  325. package/carbon/src/server/lib/email.ts +64 -0
  326. package/carbon/src/server/lib/env.ts +112 -0
  327. package/carbon/src/server/lib/errors.ts +21 -0
  328. package/carbon/src/server/lib/logger.ts +17 -0
  329. package/carbon/src/server/lib/rate-limiter.ts +288 -0
  330. package/carbon/src/server/lib/request.ts +34 -0
  331. package/carbon/src/server/lib/stripe.ts +42 -0
  332. package/carbon/src/server/lib/supabase.ts +65 -0
  333. package/carbon/src/server/middleware/requirePlan.ts +80 -0
  334. package/carbon/src/server/routes/_internal/services-status.ts +958 -0
  335. package/carbon/src/server/routes/_internal/verify-role.ts +185 -0
  336. package/carbon/src/server/routes/health.ts +48 -0
  337. package/carbon/src/server/routes/v1/admin/contact.ts +128 -0
  338. package/carbon/src/server/routes/v1/admin/jobs.ts +171 -0
  339. package/carbon/src/server/routes/v1/admin/newsletter.ts +237 -0
  340. package/carbon/src/server/routes/v1/auth.ts +390 -0
  341. package/carbon/src/server/routes/v1/billing.ts +718 -0
  342. package/carbon/src/server/routes/v1/contact.ts +93 -0
  343. package/carbon/src/server/routes/v1/index.ts +1333 -0
  344. package/carbon/src/server/routes/v1/newsletter.ts +181 -0
  345. package/carbon/src/server/routes/v1/performance.ts +157 -0
  346. package/carbon/src/server/routes/v1/stats.ts +170 -0
  347. package/carbon/src/server/routes/v1/theme.ts +106 -0
  348. package/carbon/src/server/routes/webhooks/billing.ts +376 -0
  349. package/carbon/src/server/routes/webhooks/stripe.ts +276 -0
  350. package/carbon/src/server/types.ts +11 -0
  351. package/carbon/src/shared/pricing.ts +155 -0
  352. package/carbon/src/shared/types.ts +338 -0
  353. package/carbon/supabase/migrations/00001_init.sql +717 -0
  354. package/carbon/supabase/migrations/00002_theme_settings.sql +13 -0
  355. package/carbon/supabase/migrations/00003_pg_cron.sql +121 -0
  356. package/carbon/supabase/migrations/00004_contact_newsletter.sql +81 -0
  357. package/carbon/supabase/migrations/00005_localization_languages.sql +22 -0
  358. package/carbon/supabase/seed.sql +16 -0
  359. package/carbon/tests/_helpers/app.ts +45 -0
  360. package/carbon/tests/_helpers/env.ts +37 -0
  361. package/carbon/tests/_helpers/factories.ts +69 -0
  362. package/carbon/tests/_helpers/jwt.ts +23 -0
  363. package/carbon/tests/_helpers/setup-integration.ts +20 -0
  364. package/carbon/tests/_helpers/setup-rtl.ts +12 -0
  365. package/carbon/tests/component/ErrorBoundary.test.tsx +53 -0
  366. package/carbon/tests/component/use-auth-settings.test.tsx +119 -0
  367. package/carbon/tests/integration/server/routes/contact.test.ts +162 -0
  368. package/carbon/tests/integration/server/routes/health.test.ts +61 -0
  369. package/carbon/tests/structural/i18n-parity.test.ts +42 -0
  370. package/carbon/tests/unit/client/utils.test.ts +49 -0
  371. package/carbon/tests/unit/shared/pricing.test.ts +93 -0
  372. package/carbon/tsconfig.json +27 -0
  373. package/carbon/tsconfig.server.json +27 -0
  374. package/carbon/tsconfig.test.json +9 -0
  375. package/carbon/vite.config.ts +110 -0
  376. package/carbon/vitest.config.ts +74 -0
  377. package/carbon/volumes/db/jwt.sql +57 -0
  378. package/carbon/volumes/db/metabase-init.sh +29 -0
  379. package/carbon/volumes/db/n8n-init.sh +25 -0
  380. package/carbon/volumes/db/realtime.sql +33 -0
  381. package/carbon/volumes/db/roles.sql +93 -0
  382. package/carbon/volumes/db/set-passwords.sh +12 -0
  383. package/carbon/volumes/db/super-admin.dev.sql +113 -0
  384. package/carbon/volumes/db/super-admin.generated.sql +113 -0
  385. package/carbon/volumes/db/super-admin.sql +114 -0
  386. package/carbon/volumes/grafana/dashboards/logs.json +179 -0
  387. package/carbon/volumes/grafana/dashboards/overview.json +523 -0
  388. package/carbon/volumes/grafana/dashboards/postgresql.json +337 -0
  389. package/carbon/volumes/grafana/dashboards.dev/logs.json +179 -0
  390. package/carbon/volumes/grafana/dashboards.dev/overview.json +156 -0
  391. package/carbon/volumes/grafana/dashboards.dev/postgresql.json +337 -0
  392. package/carbon/volumes/grafana/provisioning/dashboards/dashboards.dev.yml +16 -0
  393. package/carbon/volumes/grafana/provisioning/dashboards/dashboards.yml +16 -0
  394. package/carbon/volumes/grafana/provisioning/datasources/datasources.yml +43 -0
  395. package/carbon/volumes/kong/docker-entrypoint.sh +9 -0
  396. package/carbon/volumes/kong/kong.yml +208 -0
  397. package/carbon/volumes/loki/loki-config.yml +58 -0
  398. package/carbon/volumes/n8n/hooks.js +131 -0
  399. package/carbon/volumes/n8n/scripts/setup.sh +66 -0
  400. package/carbon/volumes/prometheus/prometheus.yml +43 -0
  401. package/carbon/volumes/promtail/promtail-config.yml +64 -0
  402. package/carbon/volumes/traefik/middlewares.dev.yml +83 -0
  403. package/carbon/volumes/traefik/middlewares.yml +95 -0
  404. package/carbon/volumes/traefik/vite-dev.yml +20 -0
  405. package/package.json +95 -0
  406. package/src/access.js +354 -0
  407. package/src/activate.js +187 -0
  408. package/src/add.js +718 -0
  409. package/src/backup.js +786 -0
  410. package/src/cli.js +350 -0
  411. package/src/configure.js +967 -0
  412. package/src/console.js +155 -0
  413. package/src/create.js +1499 -0
  414. package/src/deploy.js +311 -0
  415. package/src/destroy.js +2033 -0
  416. package/src/diagnose.js +735 -0
  417. package/src/down.js +80 -0
  418. package/src/failover.js +1032 -0
  419. package/src/lib/backup-s3.js +179 -0
  420. package/src/lib/build.js +33 -0
  421. package/src/lib/checksum.js +28 -0
  422. package/src/lib/ci-setup.js +666 -0
  423. package/src/lib/cli/help.js +129 -0
  424. package/src/lib/cli/parse-flags.js +160 -0
  425. package/src/lib/cli/select-action.js +65 -0
  426. package/src/lib/cli/select-environment.js +108 -0
  427. package/src/lib/cli/tty-guard.js +75 -0
  428. package/src/lib/cloudflare.js +447 -0
  429. package/src/lib/colors.js +52 -0
  430. package/src/lib/command.js +361 -0
  431. package/src/lib/config.js +359 -0
  432. package/src/lib/cost.js +103 -0
  433. package/src/lib/deploy/bundle.js +231 -0
  434. package/src/lib/deploy/compose/acme-verify.js +121 -0
  435. package/src/lib/deploy/compose/build-args.js +78 -0
  436. package/src/lib/deploy/compose/ha.js +1874 -0
  437. package/src/lib/deploy/compose/index.js +1294 -0
  438. package/src/lib/deploy/compose/reconcile.js +74 -0
  439. package/src/lib/deploy/github.js +382 -0
  440. package/src/lib/deploy/image.js +191 -0
  441. package/src/lib/deploy/index.js +119 -0
  442. package/src/lib/deploy/k8s/LICENSE +99 -0
  443. package/src/lib/deploy/k8s/gitops-deploy.js +298 -0
  444. package/src/lib/deploy/k8s/ha/index.js +1000 -0
  445. package/src/lib/deploy/k8s/index.js +62 -0
  446. package/src/lib/deploy/k8s/k3s.js +2515 -0
  447. package/src/lib/deploy/orchestrator.js +1401 -0
  448. package/src/lib/deploy/prompts.js +545 -0
  449. package/src/lib/deploy/remote-build.js +130 -0
  450. package/src/lib/deploy/state.js +120 -0
  451. package/src/lib/deploy/utils.js +328 -0
  452. package/src/lib/deploy-logger.js +93 -0
  453. package/src/lib/dns-propagation.js +48 -0
  454. package/src/lib/environment.js +58 -0
  455. package/src/lib/fetch-retry.js +103 -0
  456. package/src/lib/github-environments.js +335 -0
  457. package/src/lib/hetzner-dns.js +377 -0
  458. package/src/lib/hetzner-guided-setup.js +275 -0
  459. package/src/lib/host-keys.js +37 -0
  460. package/src/lib/iac/cloud-init.js +52 -0
  461. package/src/lib/iac/index.js +325 -0
  462. package/src/lib/iac/programs/hetzner-compose.js +130 -0
  463. package/src/lib/iac/programs/hetzner-k8s.js +320 -0
  464. package/src/lib/kubectl.js +81 -0
  465. package/src/lib/licensing/index.js +259 -0
  466. package/src/lib/licensing/tiers.js +181 -0
  467. package/src/lib/licensing/validator.js +171 -0
  468. package/src/lib/merge-package-json.js +90 -0
  469. package/src/lib/operator-ip.js +381 -0
  470. package/src/lib/package-manager.js +111 -0
  471. package/src/lib/perf.js +71 -0
  472. package/src/lib/project-guard.js +39 -0
  473. package/src/lib/project.js +334 -0
  474. package/src/lib/providers/base.js +276 -0
  475. package/src/lib/providers/hetzner-s3.js +656 -0
  476. package/src/lib/providers/hetzner.js +755 -0
  477. package/src/lib/providers/index.js +164 -0
  478. package/src/lib/s3.js +510 -0
  479. package/src/lib/secret-scan.js +583 -0
  480. package/src/lib/secrets.js +63 -0
  481. package/src/lib/server-types.js +195 -0
  482. package/src/lib/shell.js +91 -0
  483. package/src/lib/ssh.js +241 -0
  484. package/src/lib/tracker.js +242 -0
  485. package/src/lib/upgrade-policy.js +170 -0
  486. package/src/lib/validators.js +105 -0
  487. package/src/lib/version.js +5 -0
  488. package/src/remove.js +292 -0
  489. package/src/reset.js +97 -0
  490. package/src/restore.js +871 -0
  491. package/src/scale.js +1734 -0
  492. package/src/shell.js +222 -0
  493. package/src/status.js +981 -0
  494. package/src/up.js +264 -0
  495. 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 };