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,312 @@
1
+ import 'server-only'
2
+
3
+ import { validateEmail } from '@ejekanshjain/simple-email-validator'
4
+ import { createId } from '@paralleldrive/cuid2'
5
+ import { APIError, betterAuth } from 'better-auth'
6
+ import { drizzleAdapter } from 'better-auth/adapters/drizzle'
7
+ import { createAuthMiddleware } from 'better-auth/api'
8
+ import {
9
+ admin,
10
+ lastLoginMethod,
11
+ magicLink,
12
+ organization
13
+ } from 'better-auth/plugins'
14
+ import { and, count, eq, isNull, ne } from 'drizzle-orm'
15
+ import { alias } from 'drizzle-orm/pg-core'
16
+ import { headers } from 'next/headers'
17
+ import { cache } from 'react'
18
+ import { start } from 'workflow/api'
19
+ import { db } from '~/db'
20
+ import {
21
+ accountsTable,
22
+ invitationsTable,
23
+ membersTable,
24
+ organizationSubscriptionsTable,
25
+ organizationsTable,
26
+ sessionsTable,
27
+ usersTable,
28
+ verificationsTable
29
+ } from '~/db/schema'
30
+ import { env } from '~/env'
31
+ import { ac, adminRoles, roles } from './auth-permissions'
32
+ import {
33
+ INVITATION_EXPIRES_IN_DAYS,
34
+ MAX_ORGANIZATIONS_PER_USER
35
+ } from './constants'
36
+ import {
37
+ sendInvitationEmail,
38
+ sendMagicLinkEmail,
39
+ sendWelcomeEmail
40
+ } from './email-service'
41
+ import { getOrganizationPlan } from './plan-limits'
42
+ import { siteConfig } from './siteConfig'
43
+ import { deleteOrganizationUploads } from './storage'
44
+ import { stripeClient } from './stripe'
45
+
46
+ /**
47
+ * Blocks organization logos set through the Better Auth client. Logos go
48
+ * through `updateOrganizationAction`, which checks the upload belongs to the
49
+ * organization before saving it.
50
+ */
51
+ function rejectClientLogo(logo: string | null | undefined) {
52
+ if (logo) {
53
+ throw new APIError('BAD_REQUEST', {
54
+ message: 'Change the logo from the organization settings page.'
55
+ })
56
+ }
57
+ }
58
+
59
+ /** True when `userId` is the only owner of at least one organization. */
60
+ async function ownsOrganizationAlone(userId: string): Promise<boolean> {
61
+ const otherOwners = alias(membersTable, 'other_owners')
62
+
63
+ const [row] = await db
64
+ .select({ organizationId: membersTable.organizationId })
65
+ .from(membersTable)
66
+ .leftJoin(
67
+ otherOwners,
68
+ and(
69
+ eq(otherOwners.organizationId, membersTable.organizationId),
70
+ eq(otherOwners.role, 'owner'),
71
+ ne(otherOwners.userId, userId)
72
+ )
73
+ )
74
+ .where(
75
+ and(
76
+ eq(membersTable.userId, userId),
77
+ eq(membersTable.role, 'owner'),
78
+ isNull(otherOwners.id)
79
+ )
80
+ )
81
+ .limit(1)
82
+
83
+ return Boolean(row)
84
+ }
85
+
86
+ /**
87
+ * Better Auth configuration.
88
+ *
89
+ * Features:
90
+ * - Magic link email sign-in
91
+ * - OAuth (GitHub, Google) with account linking
92
+ * - Admin plugin with `user`, `admin`, and `superadmin` roles
93
+ * - Organization plugin with owner, admin, and member roles
94
+ */
95
+ export const auth = betterAuth({
96
+ database: drizzleAdapter(db, {
97
+ provider: 'pg',
98
+ schema: {
99
+ user: usersTable,
100
+ session: sessionsTable,
101
+ account: accountsTable,
102
+ verification: verificationsTable,
103
+ organization: organizationsTable,
104
+ member: membersTable,
105
+ invitation: invitationsTable
106
+ }
107
+ }),
108
+ account: {
109
+ accountLinking: {
110
+ enabled: true
111
+ }
112
+ },
113
+ advanced: {
114
+ database: {
115
+ generateId: ({ model }) => `${model}_${createId()}`
116
+ }
117
+ },
118
+ socialProviders: {
119
+ github: {
120
+ prompt: 'select_account',
121
+ clientId: env.BETTER_AUTH_GITHUB_ID,
122
+ clientSecret: env.BETTER_AUTH_GITHUB_SECRET
123
+ },
124
+ google: {
125
+ prompt: 'select_account',
126
+ clientId: env.BETTER_AUTH_GOOGLE_ID,
127
+ clientSecret: env.BETTER_AUTH_GOOGLE_SECRET
128
+ }
129
+ },
130
+ databaseHooks: {
131
+ user: {
132
+ create: {
133
+ // Magic-link sign-up sends an empty name; fall back to the email's
134
+ // local part so greetings and avatars never render blank.
135
+ before: async user => ({
136
+ data: { ...user, name: user.name.trim() || user.email.split('@')[0]! }
137
+ }),
138
+ after: async user => {
139
+ try {
140
+ await start(sendWelcomeEmail, [
141
+ user.email,
142
+ {
143
+ userName: user.name,
144
+ companyName: siteConfig.name
145
+ }
146
+ ])
147
+ } catch (error) {
148
+ console.error('Failed to send welcome email:', error)
149
+ }
150
+ }
151
+ },
152
+ delete: {
153
+ before: async user => {
154
+ if (await ownsOrganizationAlone(user.id)) {
155
+ throw new APIError('BAD_REQUEST', {
156
+ message:
157
+ 'This user is the only owner of an organization. Transfer ownership or delete the organization first.'
158
+ })
159
+ }
160
+ }
161
+ }
162
+ }
163
+ },
164
+ plugins: [
165
+ magicLink({
166
+ sendMagicLink: async ({ email, url }) => {
167
+ try {
168
+ await start(sendMagicLinkEmail, [
169
+ email,
170
+ {
171
+ magicLink: url,
172
+ companyName: siteConfig.name
173
+ }
174
+ ])
175
+ } catch (error) {
176
+ console.error('Failed to send magic link email:', error)
177
+ }
178
+ }
179
+ }),
180
+ admin({
181
+ ac,
182
+ roles,
183
+ adminRoles: [...adminRoles],
184
+ defaultRole: 'user'
185
+ }),
186
+ organization({
187
+ organizationLimit: MAX_ORGANIZATIONS_PER_USER,
188
+ invitationExpiresIn: INVITATION_EXPIRES_IN_DAYS * 24 * 60 * 60,
189
+ cancelPendingInvitationsOnReInvite: true,
190
+ sendInvitationEmail: async ({
191
+ id,
192
+ email,
193
+ role,
194
+ organization,
195
+ inviter
196
+ }) => {
197
+ try {
198
+ await start(sendInvitationEmail, [
199
+ email,
200
+ {
201
+ inviterName: inviter.user.name,
202
+ organizationName: organization.name,
203
+ inviteUrl: `${env.BETTER_AUTH_URL}/accept-invitation/${id}`,
204
+ role,
205
+ companyName: siteConfig.name
206
+ }
207
+ ])
208
+ } catch (error) {
209
+ console.error('Failed to send invitation email:', error)
210
+ }
211
+ },
212
+ organizationHooks: {
213
+ beforeCreateOrganization: async ({ organization }) => {
214
+ rejectClientLogo(organization.logo)
215
+ },
216
+ afterCreateOrganization: async ({ organization }) => {
217
+ await db.insert(organizationSubscriptionsTable).values({
218
+ organizationId: organization.id
219
+ })
220
+ },
221
+ beforeUpdateOrganization: async ({ organization }) => {
222
+ rejectClientLogo(organization.logo)
223
+ },
224
+ // Files go first: if R2 fails, the organization and its paid plan
225
+ // both stay intact. Stripe is cancelled only once nothing else can fail.
226
+ beforeDeleteOrganization: async ({ organization }) => {
227
+ await deleteOrganizationUploads(organization.id)
228
+
229
+ const sub = await db.query.organizationSubscriptionsTable.findFirst({
230
+ where: eq(
231
+ organizationSubscriptionsTable.organizationId,
232
+ organization.id
233
+ ),
234
+ columns: { stripeSubscriptionId: true }
235
+ })
236
+
237
+ if (sub?.stripeSubscriptionId) {
238
+ try {
239
+ await stripeClient.subscriptions.cancel(sub.stripeSubscriptionId)
240
+ } catch (error) {
241
+ console.error(
242
+ `Failed to cancel Stripe subscription ${sub.stripeSubscriptionId} while deleting organization ${organization.id}:`,
243
+ error
244
+ )
245
+ throw new APIError('BAD_REQUEST', {
246
+ message:
247
+ 'We could not cancel the active subscription, so the organization was kept. Try again in a few minutes.'
248
+ })
249
+ }
250
+ }
251
+ }
252
+ },
253
+ // Better Auth caps pending invitations at this number, so leave room
254
+ // only for the seats current members do not fill.
255
+ invitationLimit: async ({ organization }) => {
256
+ const [{ plan }, [row]] = await Promise.all([
257
+ getOrganizationPlan(organization.id),
258
+ db
259
+ .select({ count: count() })
260
+ .from(membersTable)
261
+ .where(eq(membersTable.organizationId, organization.id))
262
+ ])
263
+ return Math.max(0, plan.maxMembers - (row?.count ?? 0))
264
+ },
265
+ membershipLimit: async (_, organization) => {
266
+ const { plan } = await getOrganizationPlan(organization.id)
267
+ return plan.maxMembers
268
+ }
269
+ }),
270
+ lastLoginMethod()
271
+ ],
272
+ hooks: {
273
+ before: createAuthMiddleware(async ctx => {
274
+ if (ctx.path === '/sign-in/magic-link' && ctx.body?.email) {
275
+ try {
276
+ const validation = await validateEmail({
277
+ email: ctx.body.email
278
+ })
279
+
280
+ if (validation.isValid === false) {
281
+ throw new APIError('BAD_REQUEST', {
282
+ message: 'Please use a valid email address.'
283
+ })
284
+ }
285
+ } catch (err) {
286
+ console.error('Email validation failed', err)
287
+ }
288
+ }
289
+ })
290
+ }
291
+ })
292
+
293
+ /**
294
+ * The current session, cached per request.
295
+ *
296
+ * Returns null when signed out. `isAdmin` is true for both `admin` and
297
+ * `superadmin` (admin panel access). `isSuperAdmin` is true only for
298
+ * `superadmin` (user management, role changes, and impersonation).
299
+ */
300
+ export const getAuthSession = cache(async () => {
301
+ const authSession = await auth.api.getSession({
302
+ headers: await headers()
303
+ })
304
+
305
+ if (!authSession || !authSession.user || !authSession.session) return null
306
+
307
+ const role = authSession.user.role
308
+ const isSuperAdmin = role === 'superadmin'
309
+ const isAdmin = role === 'admin' || isSuperAdmin
310
+
311
+ return { ...authSession, isAdmin, isSuperAdmin }
312
+ })
@@ -0,0 +1,152 @@
1
+ import 'server-only'
2
+
3
+ import { eq } from 'drizzle-orm'
4
+ import { db } from '~/db'
5
+ import {
6
+ organizationSubscriptionStatusEnum,
7
+ organizationSubscriptionsTable,
8
+ organizationsTable
9
+ } from '~/db/schema'
10
+ import { AppError, AppErrorCode } from './errors'
11
+ import { PLANS, hasLiveSubscription, type PlanId } from './plans'
12
+ import { stripeClient } from './stripe'
13
+
14
+ type SubscriptionRow = typeof organizationSubscriptionsTable.$inferSelect
15
+ type SubscriptionStatus = SubscriptionRow['status']
16
+
17
+ const KNOWN_STATUSES = new Set<string>(
18
+ organizationSubscriptionStatusEnum.enumValues
19
+ )
20
+
21
+ /** Maps a Stripe price back to the plan and billing interval it belongs to. */
22
+ export const getPlanAndIntervalByPriceId = (
23
+ priceId: string
24
+ ): { planId: PlanId; interval: 'month' | 'year' } | null => {
25
+ for (const plan of PLANS) {
26
+ if (!plan.stripePrice) continue
27
+ if (plan.stripePrice.monthly.id === priceId)
28
+ return { planId: plan.id, interval: 'month' }
29
+ if (plan.stripePrice.annual.id === priceId)
30
+ return { planId: plan.id, interval: 'year' }
31
+ }
32
+ return null
33
+ }
34
+
35
+ /** One trial per organization, and only from the free plan. */
36
+ export const canStartTrial = (
37
+ sub: Pick<SubscriptionRow, 'status' | 'hasUsedTrial'>
38
+ ): boolean => sub.status === 'free' && !sub.hasUsedTrial
39
+
40
+ /**
41
+ * Returns the organization's Stripe customer, creating one on first checkout.
42
+ * A billing email is required first so receipts have somewhere to go.
43
+ */
44
+ export const ensureStripeCustomer = async (
45
+ sub: SubscriptionRow
46
+ ): Promise<string> => {
47
+ if (sub.stripeCustomerId) return sub.stripeCustomerId
48
+
49
+ if (!sub.billingEmail) {
50
+ throw new AppError(
51
+ AppErrorCode.BAD_REQUEST,
52
+ 'Add a billing email before you choose a paid plan.'
53
+ )
54
+ }
55
+
56
+ const org = await db.query.organizationsTable.findFirst({
57
+ where: eq(organizationsTable.id, sub.organizationId),
58
+ columns: { name: true }
59
+ })
60
+
61
+ // The idempotency key makes a double-clicked checkout reuse one customer
62
+ // instead of orphaning the first one's webhooks.
63
+ const customer = await stripeClient.customers.create(
64
+ {
65
+ email: sub.billingEmail,
66
+ name: org?.name,
67
+ metadata: { organizationId: sub.organizationId }
68
+ },
69
+ { idempotencyKey: `organization-${sub.organizationId}-customer` }
70
+ )
71
+
72
+ await db
73
+ .update(organizationSubscriptionsTable)
74
+ .set({ stripeCustomerId: customer.id })
75
+ .where(eq(organizationSubscriptionsTable.id, sub.id))
76
+
77
+ return customer.id
78
+ }
79
+
80
+ /**
81
+ * Copies the customer's latest Stripe subscription onto the organization.
82
+ *
83
+ * Webhooks call this for every relevant event, so it reads the current state
84
+ * from Stripe instead of trusting the event payload, which may arrive out of
85
+ * order.
86
+ */
87
+ export const syncSubscriptionFromStripe = async (
88
+ customerId: string
89
+ ): Promise<void> => {
90
+ const subs = await stripeClient.subscriptions.list({
91
+ customer: customerId,
92
+ status: 'all',
93
+ limit: 10
94
+ })
95
+
96
+ // Newest first. Prefer a subscription that still bills over a newer one
97
+ // that ended, so the organization never drops to free while paying.
98
+ const sub =
99
+ subs.data.find(item => hasLiveSubscription(item.status)) ?? subs.data[0]
100
+
101
+ if (
102
+ !sub ||
103
+ sub.status === 'canceled' ||
104
+ sub.status === 'incomplete_expired'
105
+ ) {
106
+ await db
107
+ .update(organizationSubscriptionsTable)
108
+ .set({
109
+ plan: 'free',
110
+ status: 'free',
111
+ interval: null,
112
+ stripeSubscriptionId: null,
113
+ trialEndsAt: null,
114
+ currentPeriodEnd: null,
115
+ cancelAtPeriodEnd: false
116
+ })
117
+ .where(eq(organizationSubscriptionsTable.stripeCustomerId, customerId))
118
+ return
119
+ }
120
+
121
+ if (!KNOWN_STATUSES.has(sub.status)) {
122
+ throw new AppError(
123
+ AppErrorCode.INTERNAL,
124
+ `Stripe subscription ${sub.id} has unknown status ${sub.status}. Add it to the status enum to sync it.`
125
+ )
126
+ }
127
+
128
+ const item = sub.items.data[0]
129
+ const resolved = item ? getPlanAndIntervalByPriceId(item.price.id) : null
130
+
131
+ if (!resolved) {
132
+ console.error(
133
+ `Stripe price ${item?.price.id} on subscription ${sub.id} does not map to a plan; the organization stays on the free plan.`
134
+ )
135
+ }
136
+
137
+ const periodEnd = item?.current_period_end ?? null
138
+
139
+ await db
140
+ .update(organizationSubscriptionsTable)
141
+ .set({
142
+ plan: resolved?.planId ?? 'free',
143
+ status: sub.status as SubscriptionStatus,
144
+ interval: resolved?.interval ?? null,
145
+ stripeSubscriptionId: sub.id,
146
+ trialEndsAt: sub.trial_end ? new Date(sub.trial_end * 1000) : null,
147
+ currentPeriodEnd: periodEnd ? new Date(periodEnd * 1000) : null,
148
+ cancelAtPeriodEnd: sub.cancel_at_period_end,
149
+ ...(sub.status === 'trialing' ? { hasUsedTrial: true } : {})
150
+ })
151
+ .where(eq(organizationSubscriptionsTable.stripeCustomerId, customerId))
152
+ }
@@ -0,0 +1,28 @@
1
+ /** Origin used only to resolve a candidate callback and check where it lands. */
2
+ const RESOLUTION_ORIGIN = 'https://callback.invalid'
3
+
4
+ /**
5
+ * Narrow a caller-supplied `?callbackUrl=` down to a same-origin path.
6
+ *
7
+ * Returns the normalised path, or null when the value could leave the site.
8
+ * A leading `/` is not enough on its own: `//evil.com`, `/\evil.com`, and
9
+ * `/<tab>/evil.com` all resolve cross-origin in a browser, the last because
10
+ * URL parsing strips tabs and newlines first. The shape checks below reject
11
+ * the known forms, and resolving against a throwaway origin catches the rest.
12
+ */
13
+ export function sanitizeCallbackUrl(
14
+ value: string | string[] | undefined
15
+ ): string | null {
16
+ if (typeof value !== 'string') return null
17
+ if (!/^\/(?![/\\])/.test(value)) return null
18
+ if (/[\t\n\r]/.test(value)) return null
19
+
20
+ try {
21
+ const resolved = new URL(value, RESOLUTION_ORIGIN)
22
+ if (resolved.origin !== RESOLUTION_ORIGIN) return null
23
+
24
+ return `${resolved.pathname}${resolved.search}${resolved.hash}`
25
+ } catch {
26
+ return null
27
+ }
28
+ }
@@ -0,0 +1,4 @@
1
+ import { clsx, type ClassValue } from 'clsx'
2
+ import { twMerge } from 'tailwind-merge'
3
+
4
+ export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs))
@@ -0,0 +1,91 @@
1
+ /**
2
+ * @fileoverview Application-wide constants for configuration and validation.
3
+ *
4
+ * Read limits, labels, and repeated values from here in the UI, validation,
5
+ * tests, and docs, so one change updates every place.
6
+ *
7
+ * @module lib/constants
8
+ */
9
+
10
+ // ============================================
11
+ // BILLING
12
+ // ============================================
13
+
14
+ /** Length of the free trial on paid plans that offer one */
15
+ export const TRIAL_DAYS = 7
16
+
17
+ // ============================================
18
+ // ORGANIZATIONS
19
+ // ============================================
20
+
21
+ /** Organizations one user can create */
22
+ export const MAX_ORGANIZATIONS_PER_USER = 10
23
+
24
+ /** Days an organization invitation stays valid */
25
+ export const INVITATION_EXPIRES_IN_DAYS = 7
26
+
27
+ // ============================================
28
+ // R2 STORAGE
29
+ // ============================================
30
+
31
+ /** Seconds the client has to complete a presigned upload */
32
+ export const PRESIGNED_URL_TTL_SECONDS = 900
33
+
34
+ /** Age after which an unconfirmed upload is deleted */
35
+ export const TEMP_CLEANUP_THRESHOLD_MS = 2 * 60 * 60 * 1000
36
+
37
+ /** Maximum upload size in MB */
38
+ export const MAX_FILE_SIZE_MB = 5
39
+
40
+ /** Maximum upload size in bytes */
41
+ export const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024
42
+
43
+ /** MIME types the upload flow accepts, mapped to the extension stored on the object key */
44
+ export const R2_UPLOAD_EXTENSION_BY_MIME_TYPE = {
45
+ 'image/jpeg': 'jpg',
46
+ 'image/png': 'png',
47
+ 'image/webp': 'webp',
48
+ 'image/gif': 'gif'
49
+ } as const
50
+
51
+ export type R2UploadMimeType = keyof typeof R2_UPLOAD_EXTENSION_BY_MIME_TYPE
52
+
53
+ // ============================================
54
+ // REQUEST ARRAY BOUNDS
55
+ // ============================================
56
+
57
+ /*
58
+ * Ceilings for arrays that reach SQL as an `IN (…)` list. Postgres caps a
59
+ * statement at 65535 bind parameters, and an unbounded array also builds in
60
+ * server memory first.
61
+ */
62
+
63
+ /** Values accepted in one list-filter array, such as status facets */
64
+ export const MAX_FILTER_VALUES = 100
65
+
66
+ // ============================================
67
+ // TEXT AND INTEGER INPUT BOUNDS
68
+ // ============================================
69
+
70
+ /**
71
+ * Image value ceiling.
72
+ * Covers R2 object keys and base64 data URLs for the max upload size.
73
+ */
74
+ export const MAX_IMAGE_URL_LENGTH =
75
+ Math.ceil(MAX_FILE_SIZE_BYTES * (4 / 3)) + 64
76
+
77
+ /** URL slug for organizations */
78
+ export const MAX_SLUG_LENGTH = 64
79
+
80
+ /** Feedback comments */
81
+ export const MAX_COMMENT_LENGTH = 3_000
82
+
83
+ /** Support ticket bodies and contact form messages */
84
+ export const MAX_MESSAGE_LENGTH = 5_000
85
+
86
+ /** Shortest contact form message worth answering */
87
+ export const MIN_CONTACT_MESSAGE_LENGTH = 10
88
+
89
+ /** Star ratings on feedback */
90
+ export const MIN_RATING = 1
91
+ export const MAX_RATING = 5
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Creates a promise that resolves after a specified delay
3
+ * Useful for adding delays in async functions or testing
4
+ *
5
+ * @param ms - Milliseconds to delay
6
+ * @returns Promise that resolves after the delay
7
+ *
8
+ * @example
9
+ * await delay(1000) // Wait 1 second
10
+ */
11
+ export const delay = (ms: number) =>
12
+ new Promise(resolve => setTimeout(resolve, ms))