create-ej-app 25.0.0 → 27.0.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 (385) hide show
  1. package/README.md +125 -226
  2. package/cli.ts +83 -28
  3. package/package.json +2 -2
  4. package/template/api/.vscode/settings.json +1 -1
  5. package/template/api/{.github/copilot-instructions.md → AGENTS.md} +0 -2
  6. package/template/api/CLAUDE.md +1 -0
  7. package/template/api/package.json +10 -10
  8. package/template/nextjs/.dockerignore +1 -0
  9. package/template/nextjs/AGENTS.md +260 -0
  10. package/template/nextjs/CLAUDE.md +1 -0
  11. package/template/nextjs/README.md +109 -0
  12. package/template/nextjs/bunfig.toml +3 -0
  13. package/template/nextjs/eslint.config.mjs +6 -5
  14. package/template/nextjs/gitignore +6 -0
  15. package/template/nextjs/next.config.ts +8 -1
  16. package/template/nextjs/package.json +47 -40
  17. package/template/nextjs/public/logo-dark.svg +4 -0
  18. package/template/nextjs/public/logo.svg +4 -0
  19. package/template/nextjs/src/app/(admin)/actions/users.ts +183 -0
  20. package/template/nextjs/src/app/(admin)/admin/_components/admin-header.tsx +24 -0
  21. package/template/nextjs/src/app/(admin)/admin/_components/admin-search.tsx +16 -0
  22. package/template/nextjs/src/app/(admin)/admin/_components/admin-sidebar.tsx +42 -0
  23. package/template/nextjs/src/app/(admin)/admin/layout.tsx +53 -0
  24. package/template/nextjs/src/app/(admin)/admin/page.tsx +11 -2
  25. package/template/nextjs/src/app/(admin)/admin/settings/layout.tsx +24 -0
  26. package/template/nextjs/src/app/(admin)/admin/settings/users/[userId]/page.tsx +43 -0
  27. package/template/nextjs/src/app/(admin)/admin/settings/users/new/page.tsx +16 -0
  28. package/template/nextjs/src/app/(admin)/admin/settings/users/page.tsx +377 -0
  29. package/template/nextjs/src/app/(admin)/admin/settings/users/user-form.tsx +138 -0
  30. package/template/nextjs/src/app/(admin)/admin/settings/users/validation.ts +23 -0
  31. package/template/nextjs/src/app/(app)/app/layout.tsx +44 -0
  32. package/template/nextjs/src/app/(app)/app/page.tsx +14 -2
  33. package/template/nextjs/src/app/(auth)/(account)/layout.tsx +64 -0
  34. package/template/nextjs/src/app/(auth)/(account)/profile/page.tsx +16 -0
  35. package/template/nextjs/src/app/(auth)/(account)/profile/profile-form.tsx +247 -0
  36. package/template/nextjs/src/app/(auth)/login/email-login-form.tsx +56 -29
  37. package/template/nextjs/src/app/(auth)/login/page.tsx +45 -12
  38. package/template/nextjs/src/app/(auth)/login/social-login-buttons.tsx +85 -17
  39. package/template/nextjs/src/app/(marketing)/layout.tsx +35 -4
  40. package/template/nextjs/src/app/(marketing)/legal.tsx +56 -0
  41. package/template/nextjs/src/app/(marketing)/page.tsx +20 -2
  42. package/template/nextjs/src/app/(marketing)/privacy/page.tsx +43 -2
  43. package/template/nextjs/src/app/(marketing)/terms/page.tsx +33 -2
  44. package/template/nextjs/src/app/error.tsx +37 -0
  45. package/template/nextjs/src/app/fonts/geist-mono/GeistMono-Variable.woff2 +0 -0
  46. package/template/nextjs/src/app/fonts/geist-sans/Geist-Variable.woff2 +0 -0
  47. package/template/nextjs/src/app/fonts/index.ts +60 -0
  48. package/template/nextjs/src/app/global-error.tsx +38 -0
  49. package/template/nextjs/src/app/globals.css +29 -0
  50. package/template/nextjs/src/app/layout.tsx +45 -18
  51. package/template/nextjs/src/app/not-found.tsx +25 -3
  52. package/template/nextjs/src/app/robots.ts +29 -0
  53. package/template/nextjs/src/app/sitemap.ts +21 -0
  54. package/template/nextjs/src/components/confirm-dialog.tsx +79 -0
  55. package/template/nextjs/src/components/dashboard-header.tsx +70 -0
  56. package/template/nextjs/src/components/data-table/enum.ts +5 -0
  57. package/template/nextjs/src/components/data-table/features.ts +49 -0
  58. package/template/nextjs/src/components/data-table/index.tsx +961 -0
  59. package/template/nextjs/src/components/data-table/types.ts +76 -0
  60. package/template/nextjs/src/components/error.tsx +71 -0
  61. package/template/nextjs/src/components/loader.tsx +18 -2
  62. package/template/nextjs/src/components/logo.tsx +49 -0
  63. package/template/nextjs/src/components/navigation-search.tsx +140 -0
  64. package/template/nextjs/src/components/navigation-sidebar.tsx +241 -0
  65. package/template/nextjs/src/components/page-heading.tsx +38 -0
  66. package/template/nextjs/src/components/react-query-provider.tsx +41 -2
  67. package/template/nextjs/src/components/route-error.tsx +55 -0
  68. package/template/nextjs/src/components/screen-size.tsx +36 -0
  69. package/template/nextjs/src/components/site-footer.tsx +20 -4
  70. package/template/nextjs/src/components/theme-provider.tsx +48 -0
  71. package/template/nextjs/src/components/theme-toggle.tsx +58 -5
  72. package/template/nextjs/src/components/ui/alert-dialog.tsx +199 -0
  73. package/template/nextjs/src/components/ui/avatar.tsx +112 -0
  74. package/template/nextjs/src/components/ui/badge.tsx +54 -0
  75. package/template/nextjs/src/components/ui/button.tsx +3 -1
  76. package/template/nextjs/src/components/ui/card.tsx +4 -4
  77. package/template/nextjs/src/components/ui/collapsible.tsx +33 -0
  78. package/template/nextjs/src/components/ui/command.tsx +192 -0
  79. package/template/nextjs/src/components/ui/dialog.tsx +164 -0
  80. package/template/nextjs/src/components/ui/dropdown-menu.tsx +273 -0
  81. package/template/nextjs/src/components/ui/input-group.tsx +156 -0
  82. package/template/nextjs/src/components/ui/select.tsx +195 -0
  83. package/template/nextjs/src/components/ui/sheet.tsx +143 -0
  84. package/template/nextjs/src/components/ui/sidebar.tsx +702 -0
  85. package/template/nextjs/src/components/ui/skeleton.tsx +13 -0
  86. package/template/nextjs/src/components/ui/table.tsx +116 -0
  87. package/template/nextjs/src/components/ui/textarea.tsx +18 -0
  88. package/template/nextjs/src/components/ui/tooltip.tsx +57 -0
  89. package/template/nextjs/src/components/user-menu.tsx +206 -0
  90. package/template/nextjs/src/db/common.ts +10 -0
  91. package/template/nextjs/src/db/index.ts +16 -6
  92. package/template/nextjs/src/db/relations.ts +21 -0
  93. package/template/nextjs/src/db/schema.ts +85 -46
  94. package/template/nextjs/src/emails/components/email-layout.tsx +27 -11
  95. package/template/nextjs/src/emails/magic-link.tsx +15 -13
  96. package/template/nextjs/src/emails/test.tsx +11 -9
  97. package/template/nextjs/src/emails/welcome.tsx +12 -9
  98. package/template/nextjs/src/env.ts +1 -1
  99. package/template/nextjs/src/hooks/use-mobile.ts +14 -12
  100. package/template/nextjs/src/instrumentation.ts +20 -2
  101. package/template/nextjs/src/lib/actions.ts +55 -0
  102. package/template/nextjs/src/lib/admin-navigation.ts +43 -0
  103. package/template/nextjs/src/lib/auth-client.ts +20 -9
  104. package/template/nextjs/src/lib/auth-permissions.ts +61 -0
  105. package/template/nextjs/src/lib/auth.ts +33 -3
  106. package/template/nextjs/src/lib/callback-url.ts +28 -0
  107. package/template/nextjs/src/lib/constants.ts +21 -0
  108. package/template/nextjs/src/lib/delay.ts +10 -0
  109. package/template/nextjs/src/lib/email-service.ts +21 -0
  110. package/template/nextjs/src/lib/errors.ts +74 -0
  111. package/template/nextjs/src/lib/format-currency.ts +22 -24
  112. package/template/nextjs/src/lib/format-date.ts +69 -6
  113. package/template/nextjs/src/lib/nodemailer.ts +27 -3
  114. package/template/nextjs/src/lib/otel.ts +92 -2
  115. package/template/nextjs/src/lib/safe-action-client.ts +60 -9
  116. package/template/nextjs/src/lib/safe-action.ts +152 -16
  117. package/template/nextjs/src/lib/search.ts +16 -0
  118. package/template/nextjs/src/lib/seo.ts +7 -0
  119. package/template/nextjs/src/lib/siteConfig.ts +17 -1
  120. package/template/nextjs/src/lib/toast-message.ts +87 -1
  121. package/template/nextjs/src/lib/validations.ts +30 -0
  122. package/template/nextjs/src/proxy.ts +48 -0
  123. package/template/nextjs/src/scripts/reset-db.ts +22 -0
  124. package/template/nextjs/src/scripts/seed.ts +59 -8
  125. package/template/nextjs/src/scripts/test-mail.ts +28 -20
  126. package/template/nextjs/tests/admin/users.test.ts +64 -0
  127. package/template/nextjs/tests/db/fk-indexes.test.ts +62 -0
  128. package/template/nextjs/tests/db/seed.test.ts +49 -0
  129. package/template/nextjs/tests/helpers/action.ts +71 -0
  130. package/template/nextjs/tests/helpers/fixtures.ts +78 -0
  131. package/template/nextjs/tests/helpers/index.ts +2 -0
  132. package/template/nextjs/tests/lib/callback-url.test.ts +48 -0
  133. package/template/nextjs/tests/lib/client-server-boundary.test.ts +57 -0
  134. package/template/nextjs/tests/lib/errors.test.ts +10 -0
  135. package/template/nextjs/tests/lib/search.test.ts +23 -0
  136. package/template/nextjs/tests/setup/pglite-db.ts +63 -0
  137. package/template/nextjs/tests/setup/preload.ts +95 -0
  138. package/template/nextjs/tsconfig.json +1 -0
  139. package/template/saas-starter/.dockerignore +8 -0
  140. package/template/saas-starter/.env.example +25 -0
  141. package/template/saas-starter/.vscode/extensions.json +11 -0
  142. package/template/saas-starter/.vscode/settings.json +3 -0
  143. package/template/saas-starter/AGENTS.md +302 -0
  144. package/template/saas-starter/CLAUDE.md +1 -0
  145. package/template/saas-starter/Dockerfile +15 -0
  146. package/template/saas-starter/README.md +125 -0
  147. package/template/saas-starter/bunfig.toml +3 -0
  148. package/template/saas-starter/components.json +25 -0
  149. package/template/saas-starter/drizzle.config.ts +10 -0
  150. package/template/saas-starter/eslint.config.mjs +24 -0
  151. package/template/saas-starter/gitignore +14 -0
  152. package/template/saas-starter/next.config.ts +32 -0
  153. package/template/saas-starter/package.json +97 -0
  154. package/template/saas-starter/postcss.config.mjs +7 -0
  155. package/template/saas-starter/prettier.config.mjs +11 -0
  156. package/template/saas-starter/public/logo-dark.svg +4 -0
  157. package/template/saas-starter/public/logo.svg +4 -0
  158. package/template/saas-starter/public/placeholder.svg +1 -0
  159. package/template/saas-starter/src/app/(admin)/actions/contact-leads.ts +51 -0
  160. package/template/saas-starter/src/app/(admin)/actions/feedbacks.ts +91 -0
  161. package/template/saas-starter/src/app/(admin)/actions/users.ts +183 -0
  162. package/template/saas-starter/src/app/(admin)/admin/_components/admin-header.tsx +24 -0
  163. package/template/saas-starter/src/app/(admin)/admin/_components/admin-search.tsx +16 -0
  164. package/template/saas-starter/src/app/(admin)/admin/_components/admin-sidebar.tsx +42 -0
  165. package/template/saas-starter/src/app/(admin)/admin/contact-leads/page.tsx +123 -0
  166. package/template/saas-starter/src/app/(admin)/admin/feedbacks/page.tsx +161 -0
  167. package/template/saas-starter/src/app/(admin)/admin/layout.tsx +53 -0
  168. package/template/saas-starter/src/app/(admin)/admin/page.tsx +60 -0
  169. package/template/saas-starter/src/app/(admin)/admin/settings/layout.tsx +24 -0
  170. package/template/saas-starter/src/app/(admin)/admin/settings/users/[userId]/page.tsx +43 -0
  171. package/template/saas-starter/src/app/(admin)/admin/settings/users/new/page.tsx +16 -0
  172. package/template/saas-starter/src/app/(admin)/admin/settings/users/page.tsx +377 -0
  173. package/template/saas-starter/src/app/(admin)/admin/settings/users/user-form.tsx +138 -0
  174. package/template/saas-starter/src/app/(admin)/admin/settings/users/validation.ts +23 -0
  175. package/template/saas-starter/src/app/(admin)/admin/support-tickets/[ticketId]/page.tsx +17 -0
  176. package/template/saas-starter/src/app/(admin)/admin/support-tickets/page.tsx +5 -0
  177. package/template/saas-starter/src/app/(app)/actions/billing.ts +216 -0
  178. package/template/saas-starter/src/app/(app)/actions/feedbacks.ts +28 -0
  179. package/template/saas-starter/src/app/(app)/actions/feedbacks.validation.ts +12 -0
  180. package/template/saas-starter/src/app/(app)/actions/members.ts +189 -0
  181. package/template/saas-starter/src/app/(app)/actions/organizations.ts +230 -0
  182. package/template/saas-starter/src/app/(app)/actions/organizations.validation.ts +17 -0
  183. package/template/saas-starter/src/app/(app)/app/[orgId]/dashboard/page.tsx +17 -0
  184. package/template/saas-starter/src/app/(app)/app/[orgId]/layout.tsx +31 -0
  185. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/billing-email-form.tsx +92 -0
  186. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/danger-zone.tsx +357 -0
  187. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/general-settings-form.tsx +130 -0
  188. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/plans-section.tsx +222 -0
  189. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/billing/page.tsx +115 -0
  190. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/layout.tsx +24 -0
  191. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/_components/invite-member-dialog.tsx +168 -0
  192. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/_components/members-tabs.tsx +604 -0
  193. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/page.tsx +33 -0
  194. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/page.tsx +55 -0
  195. package/template/saas-starter/src/app/(app)/app/[orgId]/support-tickets/[ticketId]/page.tsx +25 -0
  196. package/template/saas-starter/src/app/(app)/app/[orgId]/support-tickets/page.tsx +25 -0
  197. package/template/saas-starter/src/app/(app)/app/_components/app-header.tsx +31 -0
  198. package/template/saas-starter/src/app/(app)/app/_components/app-search.tsx +26 -0
  199. package/template/saas-starter/src/app/(app)/app/_components/app-sidebar.tsx +30 -0
  200. package/template/saas-starter/src/app/(app)/app/_components/feedback-dialog.tsx +108 -0
  201. package/template/saas-starter/src/app/(app)/app/_components/organization-context.tsx +37 -0
  202. package/template/saas-starter/src/app/(app)/app/_components/organization-switcher.tsx +200 -0
  203. package/template/saas-starter/src/app/(app)/app/layout.tsx +64 -0
  204. package/template/saas-starter/src/app/(app)/app/onboarding/_components/create-organization-form.tsx +117 -0
  205. package/template/saas-starter/src/app/(app)/app/onboarding/page.tsx +40 -0
  206. package/template/saas-starter/src/app/(app)/app/page.tsx +76 -0
  207. package/template/saas-starter/src/app/(auth)/(account)/layout.tsx +64 -0
  208. package/template/saas-starter/src/app/(auth)/(account)/profile/page.tsx +16 -0
  209. package/template/saas-starter/src/app/(auth)/(account)/profile/profile-form.tsx +247 -0
  210. package/template/saas-starter/src/app/(auth)/(account)/support/[ticketId]/page.tsx +19 -0
  211. package/template/saas-starter/src/app/(auth)/(account)/support/page.tsx +9 -0
  212. package/template/saas-starter/src/app/(auth)/login/email-login-form.tsx +131 -0
  213. package/template/saas-starter/src/app/(auth)/login/page.tsx +105 -0
  214. package/template/saas-starter/src/app/(auth)/login/social-login-buttons.tsx +137 -0
  215. package/template/saas-starter/src/app/(invitation)/accept-invitation/[id]/accept-invitation-form.tsx +94 -0
  216. package/template/saas-starter/src/app/(invitation)/accept-invitation/[id]/page.tsx +153 -0
  217. package/template/saas-starter/src/app/(invitation)/actions/invitations.ts +32 -0
  218. package/template/saas-starter/src/app/(marketing)/contact-us/actions.ts +23 -0
  219. package/template/saas-starter/src/app/(marketing)/contact-us/contact-form.tsx +118 -0
  220. package/template/saas-starter/src/app/(marketing)/contact-us/page.tsx +20 -0
  221. package/template/saas-starter/src/app/(marketing)/contact-us/validation.ts +22 -0
  222. package/template/saas-starter/src/app/(marketing)/layout.tsx +48 -0
  223. package/template/saas-starter/src/app/(marketing)/legal.tsx +66 -0
  224. package/template/saas-starter/src/app/(marketing)/page.tsx +24 -0
  225. package/template/saas-starter/src/app/(marketing)/privacy/page.tsx +63 -0
  226. package/template/saas-starter/src/app/(marketing)/terms/page.tsx +41 -0
  227. package/template/saas-starter/src/app/actions/support-tickets.ts +510 -0
  228. package/template/saas-starter/src/app/actions/support-tickets.validation.ts +112 -0
  229. package/template/saas-starter/src/app/actions/uploads.ts +36 -0
  230. package/template/saas-starter/src/app/actions/uploads.validation.ts +24 -0
  231. package/template/saas-starter/src/app/api/auth/[...all]/route.ts +4 -0
  232. package/template/saas-starter/src/app/api/webhooks/stripe/route.ts +94 -0
  233. package/template/saas-starter/src/app/error.tsx +37 -0
  234. package/template/saas-starter/src/app/favicon.ico +0 -0
  235. package/template/saas-starter/src/app/fonts/geist-mono/GeistMono-Variable.woff2 +0 -0
  236. package/template/saas-starter/src/app/fonts/geist-sans/Geist-Variable.woff2 +0 -0
  237. package/template/saas-starter/src/app/fonts/index.ts +60 -0
  238. package/template/saas-starter/src/app/global-error.tsx +38 -0
  239. package/template/saas-starter/src/app/globals.css +269 -0
  240. package/template/saas-starter/src/app/layout.tsx +77 -0
  241. package/template/saas-starter/src/app/not-found.tsx +49 -0
  242. package/template/saas-starter/src/app/robots.ts +36 -0
  243. package/template/saas-starter/src/app/sitemap.ts +21 -0
  244. package/template/saas-starter/src/components/confirm-dialog.tsx +79 -0
  245. package/template/saas-starter/src/components/dashboard-header.tsx +70 -0
  246. package/template/saas-starter/src/components/data-table/enum.ts +5 -0
  247. package/template/saas-starter/src/components/data-table/features.ts +49 -0
  248. package/template/saas-starter/src/components/data-table/index.tsx +961 -0
  249. package/template/saas-starter/src/components/data-table/types.ts +76 -0
  250. package/template/saas-starter/src/components/error.tsx +71 -0
  251. package/template/saas-starter/src/components/file-upload.tsx +254 -0
  252. package/template/saas-starter/src/components/loader.tsx +41 -0
  253. package/template/saas-starter/src/components/logo.tsx +49 -0
  254. package/template/saas-starter/src/components/navigation-search.tsx +140 -0
  255. package/template/saas-starter/src/components/navigation-sidebar.tsx +241 -0
  256. package/template/saas-starter/src/components/page-heading.tsx +38 -0
  257. package/template/saas-starter/src/components/react-query-provider.tsx +51 -0
  258. package/template/saas-starter/src/components/route-error.tsx +55 -0
  259. package/template/saas-starter/src/components/screen-size.tsx +76 -0
  260. package/template/saas-starter/src/components/site-footer.tsx +39 -0
  261. package/template/saas-starter/src/components/support/create-support-ticket-dialog.tsx +122 -0
  262. package/template/saas-starter/src/components/support/support-ticket-conversation.tsx +236 -0
  263. package/template/saas-starter/src/components/support/support-ticket-shared.tsx +58 -0
  264. package/template/saas-starter/src/components/support/support-tickets-table.tsx +281 -0
  265. package/template/saas-starter/src/components/theme-provider.tsx +115 -0
  266. package/template/saas-starter/src/components/theme-toggle.tsx +75 -0
  267. package/template/saas-starter/src/components/ui/alert-dialog.tsx +199 -0
  268. package/template/saas-starter/src/components/ui/avatar.tsx +112 -0
  269. package/template/saas-starter/src/components/ui/badge.tsx +54 -0
  270. package/template/saas-starter/src/components/ui/button.tsx +69 -0
  271. package/template/saas-starter/src/components/ui/card.tsx +103 -0
  272. package/template/saas-starter/src/components/ui/checkbox.tsx +32 -0
  273. package/template/saas-starter/src/components/ui/collapsible.tsx +33 -0
  274. package/template/saas-starter/src/components/ui/command.tsx +192 -0
  275. package/template/saas-starter/src/components/ui/dialog.tsx +164 -0
  276. package/template/saas-starter/src/components/ui/dropdown-menu.tsx +273 -0
  277. package/template/saas-starter/src/components/ui/field.tsx +238 -0
  278. package/template/saas-starter/src/components/ui/input-group.tsx +156 -0
  279. package/template/saas-starter/src/components/ui/input.tsx +19 -0
  280. package/template/saas-starter/src/components/ui/label.tsx +24 -0
  281. package/template/saas-starter/src/components/ui/popover.tsx +89 -0
  282. package/template/saas-starter/src/components/ui/select.tsx +195 -0
  283. package/template/saas-starter/src/components/ui/separator.tsx +28 -0
  284. package/template/saas-starter/src/components/ui/sheet.tsx +143 -0
  285. package/template/saas-starter/src/components/ui/sidebar.tsx +702 -0
  286. package/template/saas-starter/src/components/ui/skeleton.tsx +13 -0
  287. package/template/saas-starter/src/components/ui/sonner.tsx +45 -0
  288. package/template/saas-starter/src/components/ui/switch.tsx +33 -0
  289. package/template/saas-starter/src/components/ui/table.tsx +116 -0
  290. package/template/saas-starter/src/components/ui/tabs.tsx +90 -0
  291. package/template/saas-starter/src/components/ui/textarea.tsx +18 -0
  292. package/template/saas-starter/src/components/ui/tooltip.tsx +57 -0
  293. package/template/saas-starter/src/components/user-menu.tsx +213 -0
  294. package/template/saas-starter/src/db/common.ts +63 -0
  295. package/template/saas-starter/src/db/enums.ts +50 -0
  296. package/template/saas-starter/src/db/index.ts +60 -0
  297. package/template/saas-starter/src/db/relations.ts +112 -0
  298. package/template/saas-starter/src/db/schema.ts +342 -0
  299. package/template/saas-starter/src/emails/components/email-layout.tsx +79 -0
  300. package/template/saas-starter/src/emails/invitation.tsx +69 -0
  301. package/template/saas-starter/src/emails/magic-link.tsx +51 -0
  302. package/template/saas-starter/src/emails/test.tsx +34 -0
  303. package/template/saas-starter/src/emails/welcome.tsx +37 -0
  304. package/template/saas-starter/src/env.ts +66 -0
  305. package/template/saas-starter/src/hooks/use-mobile.ts +21 -0
  306. package/template/saas-starter/src/instrumentation.ts +44 -0
  307. package/template/saas-starter/src/lib/actions.ts +55 -0
  308. package/template/saas-starter/src/lib/admin-navigation.ts +63 -0
  309. package/template/saas-starter/src/lib/app-navigation.ts +63 -0
  310. package/template/saas-starter/src/lib/auth-client.ts +34 -0
  311. package/template/saas-starter/src/lib/auth-permissions.ts +61 -0
  312. package/template/saas-starter/src/lib/auth.ts +312 -0
  313. package/template/saas-starter/src/lib/billing.ts +152 -0
  314. package/template/saas-starter/src/lib/callback-url.ts +28 -0
  315. package/template/saas-starter/src/lib/cn.ts +4 -0
  316. package/template/saas-starter/src/lib/constants.ts +91 -0
  317. package/template/saas-starter/src/lib/delay.ts +12 -0
  318. package/template/saas-starter/src/lib/email-service.ts +118 -0
  319. package/template/saas-starter/src/lib/errors.ts +74 -0
  320. package/template/saas-starter/src/lib/format-currency.ts +25 -0
  321. package/template/saas-starter/src/lib/format-date.ts +75 -0
  322. package/template/saas-starter/src/lib/image-url.ts +54 -0
  323. package/template/saas-starter/src/lib/nodemailer.ts +67 -0
  324. package/template/saas-starter/src/lib/organization-access.ts +73 -0
  325. package/template/saas-starter/src/lib/otel.ts +117 -0
  326. package/template/saas-starter/src/lib/plan-limits.ts +37 -0
  327. package/template/saas-starter/src/lib/plans.ts +90 -0
  328. package/template/saas-starter/src/lib/rbac.ts +44 -0
  329. package/template/saas-starter/src/lib/safe-action-client.ts +126 -0
  330. package/template/saas-starter/src/lib/safe-action.ts +187 -0
  331. package/template/saas-starter/src/lib/search.ts +16 -0
  332. package/template/saas-starter/src/lib/seo.ts +7 -0
  333. package/template/saas-starter/src/lib/siteConfig.ts +20 -0
  334. package/template/saas-starter/src/lib/storage.ts +311 -0
  335. package/template/saas-starter/src/lib/stripe.ts +9 -0
  336. package/template/saas-starter/src/lib/toast-message.ts +101 -0
  337. package/template/saas-starter/src/lib/validations.ts +95 -0
  338. package/template/saas-starter/src/proxy.ts +48 -0
  339. package/template/saas-starter/src/scripts/cleanup-temp-uploads.ts +24 -0
  340. package/template/saas-starter/src/scripts/reset-db.ts +22 -0
  341. package/template/saas-starter/src/scripts/seed.ts +85 -0
  342. package/template/saas-starter/src/scripts/setup-db.ts +72 -0
  343. package/template/saas-starter/src/scripts/setup-stripe-plans.ts +206 -0
  344. package/template/saas-starter/src/scripts/test-mail.ts +43 -0
  345. package/template/saas-starter/tests/admin/users.test.ts +64 -0
  346. package/template/saas-starter/tests/db/fk-indexes.test.ts +62 -0
  347. package/template/saas-starter/tests/db/seed.test.ts +57 -0
  348. package/template/saas-starter/tests/helpers/action.ts +71 -0
  349. package/template/saas-starter/tests/helpers/fixtures.ts +151 -0
  350. package/template/saas-starter/tests/helpers/index.ts +2 -0
  351. package/template/saas-starter/tests/lib/callback-url.test.ts +48 -0
  352. package/template/saas-starter/tests/lib/client-server-boundary.test.ts +57 -0
  353. package/template/saas-starter/tests/lib/errors.test.ts +10 -0
  354. package/template/saas-starter/tests/lib/image-url.test.ts +31 -0
  355. package/template/saas-starter/tests/lib/search.test.ts +23 -0
  356. package/template/saas-starter/tests/marketing/contact-and-feedback.test.ts +86 -0
  357. package/template/saas-starter/tests/organizations/organizations.test.ts +175 -0
  358. package/template/saas-starter/tests/setup/pglite-db.ts +67 -0
  359. package/template/saas-starter/tests/setup/preload.ts +108 -0
  360. package/template/saas-starter/tests/support/support-tickets.test.ts +145 -0
  361. package/template/saas-starter/tsconfig.json +52 -0
  362. package/cli.js +0 -143
  363. package/template/nextjs/src/app/(admin)/layout.tsx +0 -20
  364. package/template/nextjs/src/app/(app)/layout.tsx +0 -20
  365. package/template/nextjs/terraform/artifact-registry.tf +0 -5
  366. package/template/nextjs/terraform/backend-service.tf +0 -13
  367. package/template/nextjs/terraform/cleanup.sh +0 -9
  368. package/template/nextjs/terraform/cloudbuild.tf +0 -70
  369. package/template/nextjs/terraform/cloudrun.tf +0 -68
  370. package/template/nextjs/terraform/database.tf +0 -60
  371. package/template/nextjs/terraform/forwarding-rule.tf +0 -43
  372. package/template/nextjs/terraform/gitignore +0 -4
  373. package/template/nextjs/terraform/ip-address.tf +0 -11
  374. package/template/nextjs/terraform/network-endpoint-group.tf +0 -11
  375. package/template/nextjs/terraform/network.tf +0 -21
  376. package/template/nextjs/terraform/pre.sh +0 -3
  377. package/template/nextjs/terraform/providers.tf +0 -18
  378. package/template/nextjs/terraform/secret.txt +0 -11
  379. package/template/nextjs/terraform/setup.sh +0 -25
  380. package/template/nextjs/terraform/ssl-certs.tf +0 -32
  381. package/template/nextjs/terraform/target-https-proxy copy.tf +0 -14
  382. package/template/nextjs/terraform/target-https-proxy.tf +0 -14
  383. package/template/nextjs/terraform/terraform.tfvars +0 -4
  384. package/template/nextjs/terraform/url-map.tf +0 -23
  385. package/template/nextjs/terraform/variables.tf +0 -21
@@ -0,0 +1,118 @@
1
+ import 'server-only'
2
+
3
+ import { render, toPlainText } from '@react-email/render'
4
+ import InvitationEmail, { InvitationEmailProps } from '~/emails/invitation'
5
+ import MagicLinkEmail, { MagicLinkEmailProps } from '~/emails/magic-link'
6
+ import WelcomeEmail, { WelcomeEmailProps } from '~/emails/welcome'
7
+ import { sendEmail } from './nodemailer'
8
+
9
+ /**
10
+ * Mapping of template types to their corresponding props
11
+ */
12
+ type EmailTemplateMap = {
13
+ welcome: WelcomeEmailProps
14
+ magicLink: MagicLinkEmailProps
15
+ invitation: InvitationEmailProps
16
+ }
17
+
18
+ type EmailTemplateType = keyof EmailTemplateMap
19
+
20
+ // Map of email template components with proper type safety
21
+ const componentMap: Record<
22
+ EmailTemplateType,
23
+ (props: EmailTemplateMap[EmailTemplateType]) => React.ReactElement
24
+ > = {
25
+ welcome: props => WelcomeEmail(props as WelcomeEmailProps),
26
+ magicLink: props => MagicLinkEmail(props as MagicLinkEmailProps),
27
+ invitation: props => InvitationEmail(props as InvitationEmailProps)
28
+ }
29
+
30
+ /**
31
+ * Generic email template renderer with type-safe overloads
32
+ */
33
+ async function renderEmailTemplate<T extends EmailTemplateType>(
34
+ templateType: T,
35
+ props: EmailTemplateMap[T]
36
+ ): Promise<{ html: string; text: string }>
37
+
38
+ async function renderEmailTemplate(
39
+ templateType: EmailTemplateType,
40
+ props: EmailTemplateMap[EmailTemplateType]
41
+ ) {
42
+ 'use step'
43
+
44
+ const emailComponent = componentMap[templateType](props)
45
+
46
+ const html = await render(emailComponent)
47
+ const text = toPlainText(html)
48
+ return { html, text }
49
+ }
50
+
51
+ /**
52
+ * Sends welcome email to newly registered users
53
+ *
54
+ * @param to - Recipient email address
55
+ * @param props - Welcome email template props
56
+ * @returns Promise resolving to SentMessageInfo from nodemailer
57
+ * @throws Error if sending the email fails
58
+ */
59
+ export async function sendWelcomeEmail(to: string, props: WelcomeEmailProps) {
60
+ 'use workflow'
61
+
62
+ const { html, text } = await renderEmailTemplate('welcome', props)
63
+
64
+ return await sendEmail({
65
+ to,
66
+ subject: `Welcome to ${props.companyName}! 🎉`,
67
+ html,
68
+ text
69
+ })
70
+ }
71
+
72
+ /**
73
+ * Sends magic link email for passwordless authentication
74
+ *
75
+ * @param to - Recipient email address
76
+ * @param props - Magic link email template props
77
+ * @returns Promise resolving to SentMessageInfo from nodemailer
78
+ * @throws Error if sending the email fails
79
+ */
80
+ export async function sendMagicLinkEmail(
81
+ to: string,
82
+ props: MagicLinkEmailProps
83
+ ) {
84
+ 'use workflow'
85
+
86
+ const { html, text } = await renderEmailTemplate('magicLink', props)
87
+
88
+ return await sendEmail({
89
+ to,
90
+ subject: `Sign in to ${props.companyName}`,
91
+ html,
92
+ text
93
+ })
94
+ }
95
+
96
+ /**
97
+ * Sends an organization invitation email
98
+ *
99
+ * @param to - Recipient email address
100
+ * @param props - Invitation email template props
101
+ * @returns Promise resolving to SentMessageInfo from nodemailer
102
+ * @throws Error if sending the email fails
103
+ */
104
+ export async function sendInvitationEmail(
105
+ to: string,
106
+ props: InvitationEmailProps
107
+ ) {
108
+ 'use workflow'
109
+
110
+ const { html, text } = await renderEmailTemplate('invitation', props)
111
+
112
+ return await sendEmail({
113
+ to,
114
+ subject: `${props.inviterName} invited you to join ${props.organizationName}`,
115
+ html,
116
+ text
117
+ })
118
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Expected, user-safe action failures.
3
+ *
4
+ * Throw these for domain and authorization failures. The safe-action
5
+ * `handleServerError` returns {@link AppError.message} to the client and
6
+ * records the code on the active span. Unexpected errors stay masked.
7
+ *
8
+ * Do not put stack traces, SQL, or internal IDs in `message`.
9
+ */
10
+ export const AppErrorCode = {
11
+ UNAUTHORIZED: 'UNAUTHORIZED',
12
+ FORBIDDEN: 'FORBIDDEN',
13
+ NOT_FOUND: 'NOT_FOUND',
14
+ CONFLICT: 'CONFLICT',
15
+ QUOTA_EXCEEDED: 'QUOTA_EXCEEDED',
16
+ FEATURE_NOT_INCLUDED: 'FEATURE_NOT_INCLUDED',
17
+ BAD_REQUEST: 'BAD_REQUEST',
18
+ INTERNAL: 'INTERNAL'
19
+ } as const
20
+
21
+ export type AppErrorCode = (typeof AppErrorCode)[keyof typeof AppErrorCode]
22
+
23
+ export class AppError extends Error {
24
+ readonly code: AppErrorCode
25
+ readonly meta?: Record<string, unknown>
26
+
27
+ constructor(
28
+ code: AppErrorCode,
29
+ message: string,
30
+ meta?: Record<string, unknown>
31
+ ) {
32
+ super(message)
33
+ this.name = 'AppError'
34
+ this.code = code
35
+ this.meta = meta
36
+ }
37
+ }
38
+
39
+ export function isAppError(error: unknown): error is AppError {
40
+ return error instanceof AppError
41
+ }
42
+
43
+ /**
44
+ * The Postgres SQLSTATE of a failed query. Drizzle wraps driver errors and
45
+ * keeps the original on `cause`, so read both.
46
+ */
47
+ function postgresCode(error: unknown, depth = 0): unknown {
48
+ // Drizzle adds one layer; the cap stops an error whose cause points back at
49
+ // itself from looping forever.
50
+ if (depth > 5 || typeof error !== 'object' || error === null) return undefined
51
+ if ('code' in error && typeof error.code === 'string') return error.code
52
+ return 'cause' in error ? postgresCode(error.cause, depth + 1) : undefined
53
+ }
54
+
55
+ export function isUniqueViolation(error: unknown): boolean {
56
+ return postgresCode(error) === '23505'
57
+ }
58
+
59
+ /**
60
+ * True when a delete or update is refused because another row still points at
61
+ * the row: a foreign key violation, or a `restrict` violation for keys declared
62
+ * `onDelete: 'restrict'`.
63
+ */
64
+ export function isForeignKeyViolation(error: unknown): boolean {
65
+ const code = postgresCode(error)
66
+ return code === '23503' || code === '23001'
67
+ }
68
+
69
+ export function rethrowUniqueViolation(error: unknown, message: string): never {
70
+ if (isUniqueViolation(error)) {
71
+ throw new AppError(AppErrorCode.CONFLICT, message)
72
+ }
73
+ throw error
74
+ }
@@ -0,0 +1,25 @@
1
+ const formatters = new Map<string, Intl.NumberFormat>()
2
+
3
+ /**
4
+ * Formats an amount in its currency, such as `$20.00` or `€20.00`.
5
+ * `Intl` picks the symbol and the minor digits from the ISO currency code.
6
+ *
7
+ * @example
8
+ * formatCurrency('USD', 20) // '$20.00'
9
+ */
10
+ export function formatCurrency(
11
+ currencyCode: string,
12
+ value: number | string
13
+ ): string {
14
+ let formatter = formatters.get(currencyCode)
15
+ if (!formatter) {
16
+ formatter = new Intl.NumberFormat('en-US', {
17
+ style: 'currency',
18
+ currency: currencyCode,
19
+ currencyDisplay: 'narrowSymbol',
20
+ signDisplay: 'negative'
21
+ })
22
+ formatters.set(currencyCode, formatter)
23
+ }
24
+ return formatter.format(Number(value))
25
+ }
@@ -0,0 +1,75 @@
1
+ export type DateFormatOptions = {
2
+ /** Abbreviated month ("Jan") instead of the full name. */
3
+ short?: boolean
4
+ /** Append the time of day. */
5
+ time?: boolean
6
+ /** Drop the year. */
7
+ noYear?: boolean
8
+ /** Render in this IANA zone instead of the viewer's. */
9
+ timeZone?: string
10
+ }
11
+
12
+ function asValidDate(
13
+ date: Date | string | number | null | undefined
14
+ ): Date | null {
15
+ if (date == null) return null
16
+ const instant = new Date(date)
17
+ return Number.isFinite(instant.getTime()) ? instant : null
18
+ }
19
+
20
+ /**
21
+ * Date and time are formatted separately and joined with a fixed " at ",
22
+ * because the connector ICU inserts between them changed across versions
23
+ * (", " in ICU 72 and later). Bun, Node, and each browser ship different ICU
24
+ * data, so one `toLocaleString` call renders differently per viewer and
25
+ * breaks hydration.
26
+ */
27
+ function joinDateAndTime(
28
+ instant: Date,
29
+ dateOptions: Intl.DateTimeFormatOptions,
30
+ timeOptions: Intl.DateTimeFormatOptions,
31
+ timeZone: string | undefined
32
+ ): string {
33
+ const zone = timeZone ? { timeZone } : {}
34
+ const datePart = instant.toLocaleDateString('en-US', {
35
+ ...dateOptions,
36
+ ...zone
37
+ })
38
+ const timePart = instant.toLocaleTimeString('en-US', {
39
+ ...timeOptions,
40
+ ...zone
41
+ })
42
+ return `${datePart} at ${timePart}`
43
+ }
44
+
45
+ /**
46
+ * Formats a date as an `en-US` string, such as "March 3, 2026".
47
+ * Returns `-` for a missing or invalid date.
48
+ */
49
+ export const formatDate = (
50
+ date: Date | string | number | null | undefined,
51
+ options?: DateFormatOptions
52
+ ): string => {
53
+ const instant = asValidDate(date)
54
+ if (!instant) return '-'
55
+
56
+ const dateOptions: Intl.DateTimeFormatOptions = {
57
+ ...(options?.noYear ? {} : { year: 'numeric' }),
58
+ month: options?.short ? 'short' : 'long',
59
+ day: 'numeric'
60
+ }
61
+
62
+ if (!options?.time) {
63
+ return instant.toLocaleDateString('en-US', {
64
+ ...dateOptions,
65
+ ...(options?.timeZone ? { timeZone: options.timeZone } : {})
66
+ })
67
+ }
68
+
69
+ return joinDateAndTime(
70
+ instant,
71
+ dateOptions,
72
+ { hour: '2-digit', minute: '2-digit' },
73
+ options.timeZone
74
+ )
75
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Image field formats stored on organizations.
3
+ *
4
+ * Client-safe: no env, DB, or server-only imports. Zod schemas and
5
+ * `resolveImageUrl` share these predicates so only R2 keys and data image
6
+ * URLs reach the renderer.
7
+ */
8
+ import {
9
+ R2_UPLOAD_EXTENSION_BY_MIME_TYPE,
10
+ type R2UploadMimeType
11
+ } from '~/lib/constants'
12
+
13
+ const IMAGE_EXTENSIONS = Object.values(R2_UPLOAD_EXTENSION_BY_MIME_TYPE)
14
+ const IMAGE_SUBTYPES = (
15
+ Object.keys(R2_UPLOAD_EXTENSION_BY_MIME_TYPE) as R2UploadMimeType[]
16
+ ).map(mime => mime.slice('image/'.length))
17
+
18
+ const R2_OBJECT_KEY_RE = new RegExp(
19
+ `^uploads/organizations/[a-z0-9_-]+/[a-zA-Z0-9._-]+\\.(${IMAGE_EXTENSIONS.join('|')})$`
20
+ )
21
+ const DATA_IMAGE_URL_RE = new RegExp(
22
+ `^data:image/(?:${IMAGE_SUBTYPES.join('|')});base64,[A-Za-z0-9+/]+={0,2}$`
23
+ )
24
+
25
+ /** True when `value` is an owned-upload key (`uploads/organizations/<id>/<file>.<ext>`). */
26
+ export function isR2ObjectKey(value: string): boolean {
27
+ return R2_OBJECT_KEY_RE.test(value)
28
+ }
29
+
30
+ /** True when `value` is a `data:image/*;base64,...` URL of an allowed MIME type. */
31
+ export function isDataImageUrl(value: string): boolean {
32
+ return DATA_IMAGE_URL_RE.test(value)
33
+ }
34
+
35
+ /** True when `value` is an R2 object key or a data image URL. */
36
+ export function isStoredImageValue(value: string): boolean {
37
+ return isR2ObjectKey(value) || isDataImageUrl(value)
38
+ }
39
+
40
+ /**
41
+ * Persist rules after format checks.
42
+ *
43
+ * R2 keys are always eligible (ownership is a separate DB check). Data URLs
44
+ * are only eligible when R2 is off; otherwise they skip the upload tracking
45
+ * and inline megabytes into every page that shows the image.
46
+ */
47
+ export function canPersistImageValue(
48
+ value: string,
49
+ r2Configured: boolean
50
+ ): boolean {
51
+ if (isR2ObjectKey(value)) return true
52
+ if (isDataImageUrl(value)) return !r2Configured
53
+ return false
54
+ }
@@ -0,0 +1,67 @@
1
+ import 'server-only'
2
+
3
+ import nodemailer, { SentMessageInfo } from 'nodemailer'
4
+ import { env } from '~/env'
5
+ import { AppError, AppErrorCode } from './errors'
6
+
7
+ /**
8
+ * Configure the email transporter using environment variables.
9
+ * Uses nodemailer for sending emails via SMTP.
10
+ */
11
+ const transporter = nodemailer.createTransport({
12
+ host: env.EMAIL_SERVER_HOST,
13
+ port: env.EMAIL_SERVER_PORT,
14
+ secure: env.EMAIL_SERVER_PORT === 465, // Use secure for port 465, false for other ports
15
+ auth: {
16
+ user: env.EMAIL_SERVER_USER,
17
+ pass: env.EMAIL_SERVER_PASSWORD
18
+ }
19
+ })
20
+
21
+ /**
22
+ * Interface for the email payload
23
+ */
24
+ interface SendEmailParams {
25
+ to: string
26
+ subject: string
27
+ text?: string
28
+ html?: string
29
+ }
30
+
31
+ /**
32
+ * Sends an email using the configured transporter.
33
+ *
34
+ * @param {SendEmailParams} params - The email parameters.
35
+ * @param {string} params.to - The recipient's email address.
36
+ * @param {string} params.subject - The subject of the email.
37
+ * @param {string} [params.text] - The plain text content of the email.
38
+ * @param {string} [params.html] - The HTML content of the email (optional).
39
+ * @returns {Promise<any>} Resolves when the email is sent successfully.
40
+ * @throws {Error} If sending the email fails.
41
+ */
42
+ export const sendEmail = async ({
43
+ to,
44
+ subject,
45
+ text,
46
+ html
47
+ }: SendEmailParams): Promise<SentMessageInfo> => {
48
+ 'use step'
49
+
50
+ try {
51
+ const info = await transporter.sendMail({
52
+ from: env.EMAIL_FROM,
53
+ to,
54
+ subject,
55
+ text,
56
+ html
57
+ })
58
+
59
+ return info
60
+ } catch (error) {
61
+ console.error('Error sending email:', error)
62
+ throw new AppError(
63
+ AppErrorCode.INTERNAL,
64
+ 'The email was not sent. Try again.'
65
+ )
66
+ }
67
+ }
@@ -0,0 +1,73 @@
1
+ import 'server-only'
2
+
3
+ import { and, eq, inArray } from 'drizzle-orm'
4
+ import { db } from '~/db'
5
+ import { membersTable } from '~/db/schema'
6
+ import { AppError, AppErrorCode } from './errors'
7
+ import type { OrganizationRole } from './rbac'
8
+
9
+ /**
10
+ * The caller's membership in an organization, or null when they have none.
11
+ */
12
+ export async function getMembership(organizationId: string, userId: string) {
13
+ const membership = await db.query.membersTable.findFirst({
14
+ where: and(
15
+ eq(membersTable.organizationId, organizationId),
16
+ eq(membersTable.userId, userId)
17
+ ),
18
+ columns: { id: true, role: true }
19
+ })
20
+
21
+ return membership ?? null
22
+ }
23
+
24
+ /**
25
+ * Asserts the caller belongs to the organization, in any role.
26
+ * Throws {@link AppError} with code FORBIDDEN otherwise.
27
+ */
28
+ export async function assertMember(organizationId: string, userId: string) {
29
+ const membership = await getMembership(organizationId, userId)
30
+ if (!membership) {
31
+ throw new AppError(
32
+ AppErrorCode.FORBIDDEN,
33
+ 'You do not have access to this organization.'
34
+ )
35
+ }
36
+ return membership
37
+ }
38
+
39
+ /**
40
+ * Asserts the caller holds one of `allowedRoles` in the organization.
41
+ */
42
+ export async function assertRole(
43
+ organizationId: string,
44
+ userId: string,
45
+ allowedRoles: ReadonlyArray<OrganizationRole>
46
+ ) {
47
+ const membership = await db.query.membersTable.findFirst({
48
+ where: and(
49
+ eq(membersTable.organizationId, organizationId),
50
+ eq(membersTable.userId, userId),
51
+ inArray(membersTable.role, [...allowedRoles])
52
+ ),
53
+ columns: { id: true, role: true }
54
+ })
55
+ if (!membership) {
56
+ throw new AppError(
57
+ AppErrorCode.FORBIDDEN,
58
+ 'You do not have permission to perform this action.'
59
+ )
60
+ }
61
+ return membership
62
+ }
63
+
64
+ /**
65
+ * Asserts the caller can manage the organization (owner or admin). Use it for
66
+ * settings, billing, members, and uploads.
67
+ */
68
+ export function assertCanManageOrganization(
69
+ organizationId: string,
70
+ userId: string
71
+ ) {
72
+ return assertRole(organizationId, userId, ['owner', 'admin'])
73
+ }
@@ -0,0 +1,117 @@
1
+ import { SpanStatusCode, trace, type Attributes } from '@opentelemetry/api'
2
+
3
+ const MAX_STRING_ATTR_LENGTH = 256
4
+ const MAX_ARRAY_ATTR_ITEMS = 20
5
+
6
+ /**
7
+ * Helper function to retrieve the currently active span from the OpenTelemetry context.
8
+ * Useful for attaching additional information to the current trace.
9
+ */
10
+ function getSpan() {
11
+ const span = trace.getActiveSpan()
12
+ return span
13
+ }
14
+
15
+ /**
16
+ * Records a "wide event" with arbitrary attributes to the current span.
17
+ * This allows adding high-cardinality data or domain-specific context
18
+ * to the trace without starting a new child span.
19
+ *
20
+ * @param attributes - Key-value pairs of attributes to add to the span
21
+ */
22
+ export function recordWideEvent(attributes: Attributes) {
23
+ const span = getSpan()
24
+ if (!span) return
25
+
26
+ span.setAttributes(attributes)
27
+ }
28
+
29
+ /**
30
+ * Flatten a value tree into OTEL-safe attributes under `prefix`.
31
+ * Nested objects use dotted keys; long strings are truncated; large
32
+ * arrays are capped. Use for action `parsedInput` on wide events.
33
+ */
34
+ export function flattenForWideEvent(
35
+ value: unknown,
36
+ prefix = 'input'
37
+ ): Attributes {
38
+ const out: Attributes = {}
39
+
40
+ const visit = (current: unknown, path: string) => {
41
+ if (current === null || current === undefined) return
42
+
43
+ if (typeof current === 'string') {
44
+ out[path] =
45
+ current.length > MAX_STRING_ATTR_LENGTH
46
+ ? `${current.slice(0, MAX_STRING_ATTR_LENGTH)}…`
47
+ : current
48
+ return
49
+ }
50
+
51
+ if (typeof current === 'number' || typeof current === 'boolean') {
52
+ out[path] = current
53
+ return
54
+ }
55
+
56
+ if (typeof current === 'bigint') {
57
+ out[path] = current.toString()
58
+ return
59
+ }
60
+
61
+ if (Array.isArray(current)) {
62
+ const limited = current.slice(0, MAX_ARRAY_ATTR_ITEMS)
63
+ const allPrimitive = limited.every(
64
+ item =>
65
+ item === null ||
66
+ item === undefined ||
67
+ typeof item === 'string' ||
68
+ typeof item === 'number' ||
69
+ typeof item === 'boolean'
70
+ )
71
+ if (allPrimitive) {
72
+ out[path] = limited.map(item =>
73
+ item === null || item === undefined ? '' : String(item)
74
+ )
75
+ if (current.length > MAX_ARRAY_ATTR_ITEMS) {
76
+ out[`${path}.length`] = current.length
77
+ }
78
+ return
79
+ }
80
+ out[`${path}.length`] = current.length
81
+ limited.forEach((item, index) => visit(item, `${path}.${index}`))
82
+ return
83
+ }
84
+
85
+ if (typeof current === 'object') {
86
+ for (const [key, child] of Object.entries(
87
+ current as Record<string, unknown>
88
+ )) {
89
+ visit(child, path ? `${path}.${key}` : key)
90
+ }
91
+ }
92
+ }
93
+
94
+ visit(value, prefix)
95
+ return out
96
+ }
97
+
98
+ /**
99
+ * Records an exception to the current span and sets the span status to ERROR.
100
+ * captures the error message and stack trace if available.
101
+ *
102
+ * @param err - The error to record (can be an Error object or unknown)
103
+ * @returns The trace ID associated with the error, useful for correlation
104
+ */
105
+ export function recordError(err: unknown) {
106
+ const span = getSpan()
107
+ if (!span) return
108
+
109
+ span.setStatus({
110
+ code: SpanStatusCode.ERROR,
111
+ message: err instanceof Error ? err.message : String(err)
112
+ })
113
+
114
+ span.recordException(err instanceof Error ? err : String(err))
115
+
116
+ return span.spanContext().traceId
117
+ }
@@ -0,0 +1,37 @@
1
+ import 'server-only'
2
+
3
+ import { eq } from 'drizzle-orm'
4
+ import { cache } from 'react'
5
+ import { db } from '~/db'
6
+ import { organizationSubscriptionsTable } from '~/db/schema'
7
+ import { AppError, AppErrorCode } from './errors'
8
+ import { PLANS, isActiveSubscriptionStatus } from './plans'
9
+
10
+ /**
11
+ * The plan an organization is entitled to right now, and its subscription row.
12
+ *
13
+ * Only a trialing, active, or past-due subscription grants its paid plan;
14
+ * every other status falls back to the free plan.
15
+ */
16
+ export const getOrganizationPlan = cache(async (organizationId: string) => {
17
+ const sub = await db.query.organizationSubscriptionsTable.findFirst({
18
+ where: eq(organizationSubscriptionsTable.organizationId, organizationId)
19
+ })
20
+
21
+ if (!sub) {
22
+ throw new AppError(
23
+ AppErrorCode.NOT_FOUND,
24
+ 'This organization has no subscription record.'
25
+ )
26
+ }
27
+
28
+ const planId = isActiveSubscriptionStatus(sub.status) ? sub.plan : 'free'
29
+ const plan = PLANS.find(candidate => candidate.id === planId)
30
+
31
+ if (!plan) {
32
+ // A configuration bug, not a customer error: masked and recorded.
33
+ throw new Error(`Plan ${planId} is missing from the plan registry.`)
34
+ }
35
+
36
+ return { plan, sub }
37
+ })