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,311 @@
1
+ import 'server-only'
2
+
3
+ import {
4
+ DeleteObjectCommand,
5
+ PutObjectCommand,
6
+ S3Client
7
+ } from '@aws-sdk/client-s3'
8
+ import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
9
+ import { createId } from '@paralleldrive/cuid2'
10
+ import { and, eq, lt } from 'drizzle-orm'
11
+ import { db } from '~/db'
12
+ import { fileUploadsTable } from '~/db/schema'
13
+ import { env } from '~/env'
14
+ import {
15
+ PRESIGNED_URL_TTL_SECONDS,
16
+ R2_UPLOAD_EXTENSION_BY_MIME_TYPE,
17
+ TEMP_CLEANUP_THRESHOLD_MS,
18
+ type R2UploadMimeType
19
+ } from './constants'
20
+ import { AppError, AppErrorCode } from './errors'
21
+ import {
22
+ canPersistImageValue,
23
+ isDataImageUrl,
24
+ isR2ObjectKey
25
+ } from './image-url'
26
+
27
+ /*
28
+ The R2 bucket needs this CORS policy for browser uploads:
29
+ [
30
+ {
31
+ "AllowedOrigins": ["http://localhost:3000"],
32
+ "AllowedMethods": ["PUT"],
33
+ "AllowedHeaders": ["content-type", "content-length"]
34
+ }
35
+ ]
36
+ */
37
+
38
+ /** True when all five R2 env vars are set. When false, uploads fall back to base64 data URLs. */
39
+ export function isR2Configured(): boolean {
40
+ return !!(
41
+ env.R2_ACCOUNT_ID &&
42
+ env.R2_ACCESS_KEY_ID &&
43
+ env.R2_SECRET_ACCESS_KEY &&
44
+ env.R2_BUCKET_NAME &&
45
+ env.R2_PUBLIC_URL
46
+ )
47
+ }
48
+
49
+ let _r2Client: S3Client | null = null
50
+
51
+ /**
52
+ * `requestChecksumCalculation: 'WHEN_REQUIRED'` avoids signing
53
+ * `x-amz-checksum-crc32=AAAAAA==` (empty body) into presigned PUTs, which
54
+ * breaks non-empty uploads on backends that validate the header.
55
+ */
56
+ function getR2Client(): S3Client {
57
+ if (_r2Client) return _r2Client
58
+
59
+ if (!isR2Configured()) {
60
+ // A configuration bug, not a customer error: masked and recorded.
61
+ throw new Error('R2 storage is not configured.')
62
+ }
63
+
64
+ _r2Client = new S3Client({
65
+ region: 'auto',
66
+ endpoint: `https://${env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
67
+ credentials: {
68
+ accessKeyId: env.R2_ACCESS_KEY_ID!,
69
+ secretAccessKey: env.R2_SECRET_ACCESS_KEY!
70
+ },
71
+ requestChecksumCalculation: 'WHEN_REQUIRED',
72
+ responseChecksumValidation: 'WHEN_REQUIRED'
73
+ })
74
+
75
+ return _r2Client
76
+ }
77
+
78
+ /**
79
+ * Throws unless `key` sits under `uploads/organizations/<organizationId>/` and
80
+ * has a `file_uploads` row recorded for that same organization.
81
+ *
82
+ * Call this on any R2 key that comes from user input before it is persisted,
83
+ * confirmed, or deleted, so a request can never reference another tenant's
84
+ * object.
85
+ */
86
+ export async function assertOwnedR2Key(
87
+ key: string,
88
+ organizationId: string
89
+ ): Promise<void> {
90
+ if (!key.startsWith(`uploads/organizations/${organizationId}/`)) {
91
+ throw new AppError(
92
+ AppErrorCode.FORBIDDEN,
93
+ 'You do not have permission to use this file.'
94
+ )
95
+ }
96
+
97
+ const exists = await db.query.fileUploadsTable.findFirst({
98
+ where: and(
99
+ eq(fileUploadsTable.key, key),
100
+ eq(fileUploadsTable.organizationId, organizationId)
101
+ ),
102
+ columns: { id: true }
103
+ })
104
+
105
+ if (!exists) {
106
+ throw new AppError(
107
+ AppErrorCode.FORBIDDEN,
108
+ 'You do not have permission to use this file.'
109
+ )
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Deletes a file from R2 and removes its tracking row.
115
+ *
116
+ * Trusted-caller primitive with no ownership check. Validate a key that came
117
+ * from user input with {@link assertOwnedR2Key} first.
118
+ */
119
+ export async function deleteFile(key: string): Promise<void> {
120
+ await getR2Client().send(
121
+ new DeleteObjectCommand({ Bucket: env.R2_BUCKET_NAME, Key: key })
122
+ )
123
+
124
+ await db.delete(fileUploadsTable).where(eq(fileUploadsTable.key, key))
125
+ }
126
+
127
+ /**
128
+ * Presigns a PUT for a direct browser-to-R2 upload and records it as
129
+ * temporary. Content-Type is signed, so the browser cannot upload `text/html`
130
+ * under an image key.
131
+ *
132
+ * The browser PUTs the file to `uploadUrl`, then the action that saves the
133
+ * resource calls {@link confirmUpload} with the returned `key`.
134
+ */
135
+ export async function generateUploadUrl(params: {
136
+ filename: string
137
+ mimeType: R2UploadMimeType
138
+ size: number
139
+ uploadedBy: string
140
+ organizationId: string
141
+ }): Promise<{ uploadUrl: string; key: string }> {
142
+ const ext = R2_UPLOAD_EXTENSION_BY_MIME_TYPE[params.mimeType]
143
+ const fileId = createId()
144
+ const key = `uploads/organizations/${params.organizationId}/${fileId}.${ext}`
145
+
146
+ const uploadUrl = await getSignedUrl(
147
+ getR2Client(),
148
+ new PutObjectCommand({
149
+ Bucket: env.R2_BUCKET_NAME!,
150
+ Key: key,
151
+ ContentType: params.mimeType,
152
+ ContentLength: params.size
153
+ }),
154
+ {
155
+ expiresIn: PRESIGNED_URL_TTL_SECONDS,
156
+ signableHeaders: new Set(['content-type'])
157
+ }
158
+ )
159
+
160
+ await db.insert(fileUploadsTable).values({
161
+ id: `file_upload_${fileId}`,
162
+ key,
163
+ originalName: params.filename,
164
+ mimeType: params.mimeType,
165
+ size: params.size,
166
+ isTemp: true,
167
+ uploadedBy: params.uploadedBy,
168
+ organizationId: params.organizationId
169
+ })
170
+
171
+ return { uploadUrl, key }
172
+ }
173
+
174
+ /**
175
+ * Marks an upload permanent and deletes the file it replaces.
176
+ *
177
+ * Call it in the action that saves the resource. `newKey` must belong to
178
+ * `organizationId`, and this throws otherwise. Deleting `oldKey` is
179
+ * best-effort and never blocks the save.
180
+ */
181
+ export async function confirmUpload(
182
+ newKey: string,
183
+ organizationId: string,
184
+ oldKey?: string | null
185
+ ): Promise<void> {
186
+ await assertOwnedR2Key(newKey, organizationId)
187
+
188
+ await db
189
+ .update(fileUploadsTable)
190
+ .set({ isTemp: false })
191
+ .where(eq(fileUploadsTable.key, newKey))
192
+
193
+ if (oldKey && oldKey !== newKey)
194
+ await deleteOwnedFileQuietly(oldKey, organizationId)
195
+ }
196
+
197
+ /**
198
+ * Deletes a replaced or cleared file when `organizationId` owns it. Failures
199
+ * are swallowed: a stale object costs storage, not correctness.
200
+ */
201
+ export async function deleteOwnedFileQuietly(
202
+ key: string,
203
+ organizationId: string
204
+ ): Promise<void> {
205
+ if (!isR2Key(key) || !isR2Configured()) return
206
+
207
+ const owned = await assertOwnedR2Key(key, organizationId)
208
+ .then(() => true)
209
+ .catch(() => false)
210
+
211
+ if (owned) await deleteFile(key).catch(() => {})
212
+ }
213
+
214
+ /**
215
+ * Deletes every upload an organization owns, from R2 and the database.
216
+ * `file_uploads.organization_id` restricts deletes, so the organization row
217
+ * can only go once this has run.
218
+ */
219
+ export async function deleteOrganizationUploads(
220
+ organizationId: string
221
+ ): Promise<void> {
222
+ const uploads = await db
223
+ .select({ key: fileUploadsTable.key })
224
+ .from(fileUploadsTable)
225
+ .where(eq(fileUploadsTable.organizationId, organizationId))
226
+
227
+ if (isR2Configured()) {
228
+ for (const upload of uploads) {
229
+ await deleteFile(upload.key)
230
+ }
231
+ }
232
+
233
+ await db
234
+ .delete(fileUploadsTable)
235
+ .where(eq(fileUploadsTable.organizationId, organizationId))
236
+ }
237
+
238
+ /** True when `value` is an R2 object key (`uploads/organizations/<id>/<file>.<ext>`). */
239
+ export function isR2Key(value: string | null | undefined): value is string {
240
+ return !!value && isR2ObjectKey(value)
241
+ }
242
+
243
+ /**
244
+ * Rejects image values that must not be written to a resource. This blocks
245
+ * `data:` URLs when R2 is configured. Ownership stays in {@link assertOwnedR2Key}.
246
+ */
247
+ export function assertPersistableImageValue(
248
+ value: string | null | undefined
249
+ ): void {
250
+ if (!value) return
251
+ if (canPersistImageValue(value, isR2Configured())) return
252
+ throw new AppError(
253
+ AppErrorCode.BAD_REQUEST,
254
+ 'Upload an image file. Embedded images are not stored.'
255
+ )
256
+ }
257
+
258
+ /**
259
+ * Resolves a stored image value to a renderable URL.
260
+ *
261
+ * - R2 object key: prefixed with `R2_PUBLIC_URL`
262
+ * - `data:image/*;base64,...` (dev without R2): returned as-is
263
+ * - anything else, including `https://` and `javascript:`: `null`
264
+ */
265
+ export function resolveImageUrl(
266
+ value: string | null | undefined
267
+ ): string | null {
268
+ if (!value) return null
269
+ if (isR2ObjectKey(value)) {
270
+ if (!isR2Configured()) return null
271
+ return `${env.R2_PUBLIC_URL}/${value}`
272
+ }
273
+ if (isDataImageUrl(value)) return value
274
+ return null
275
+ }
276
+
277
+ /**
278
+ * Deletes temporary uploads older than {@link TEMP_CLEANUP_THRESHOLD_MS}.
279
+ * Per-file errors are counted, not thrown, so one failure cannot abort the
280
+ * batch. `bun run storage:cleanup` runs it; schedule that command.
281
+ */
282
+ export async function cleanupTempUploads(): Promise<{
283
+ deleted: number
284
+ errors: number
285
+ }> {
286
+ const threshold = new Date(Date.now() - TEMP_CLEANUP_THRESHOLD_MS)
287
+
288
+ const stale = await db
289
+ .select({ key: fileUploadsTable.key })
290
+ .from(fileUploadsTable)
291
+ .where(
292
+ and(
293
+ eq(fileUploadsTable.isTemp, true),
294
+ lt(fileUploadsTable.createdAt, threshold)
295
+ )
296
+ )
297
+
298
+ let deleted = 0
299
+ let errors = 0
300
+
301
+ for (const file of stale) {
302
+ try {
303
+ await deleteFile(file.key)
304
+ deleted++
305
+ } catch {
306
+ errors++
307
+ }
308
+ }
309
+
310
+ return { deleted, errors }
311
+ }
@@ -0,0 +1,9 @@
1
+ import 'server-only'
2
+
3
+ import Stripe from 'stripe'
4
+ import { env } from '~/env'
5
+
6
+ /** Pinned to the API version this `stripe` SDK release was built for. Bump both together. */
7
+ export const stripeClient = new Stripe(env.STRIPE_SECRET_KEY, {
8
+ apiVersion: '2026-08-26.dahlia'
9
+ })
@@ -0,0 +1,101 @@
1
+ 'use client'
2
+
3
+ import { toast } from 'sonner'
4
+
5
+ const DEFAULT_ERROR = 'The request did not finish. Try again in a few minutes.'
6
+
7
+ type ActionResultLike = {
8
+ serverError?: string
9
+ validationErrors?: unknown
10
+ }
11
+
12
+ /**
13
+ * Displays a success toast notification to the user.
14
+ */
15
+ export const toastSuccessMessage = (message: string) =>
16
+ toast.success(message, {
17
+ richColors: true,
18
+ position: 'top-center'
19
+ })
20
+
21
+ /**
22
+ * Displays an informational toast (neutral, non-error feedback).
23
+ */
24
+ export const toastInfoMessage = (message: string) =>
25
+ toast.info(message, {
26
+ richColors: true,
27
+ position: 'top-center'
28
+ })
29
+
30
+ /**
31
+ * Displays an error toast. Uses a default message when none is provided.
32
+ */
33
+ export const toastErrorMessage = (message?: string) =>
34
+ toast.error(message || DEFAULT_ERROR, {
35
+ richColors: true,
36
+ position: 'top-center'
37
+ })
38
+
39
+ /**
40
+ * Toast an action failure from `await someAction(...)`.
41
+ * Returns true when a toast was shown so callers can bail.
42
+ *
43
+ * @example
44
+ * const result = await upsertTableAction(values)
45
+ * if (toastIfActionFailed(result)) return
46
+ */
47
+ export function toastIfActionFailed(
48
+ result: ActionResultLike | undefined | null
49
+ ): boolean {
50
+ if (!result) return false
51
+ if (result.serverError) {
52
+ toastErrorMessage(result.serverError)
53
+ return true
54
+ }
55
+ if (result.validationErrors) {
56
+ toastErrorMessage('Check the highlighted fields and try again.')
57
+ return true
58
+ }
59
+ return false
60
+ }
61
+
62
+ /**
63
+ * Toast a failure from hooks (`useSafeActionMutation`, `useAction`) or a thrown Error.
64
+ *
65
+ * Accepts:
66
+ * - `SafeActionError` / `Error` (`message`)
67
+ * - `useAction` error bag (`{ serverError, validationErrors }`)
68
+ * - plain string
69
+ *
70
+ * @example
71
+ * onError: error => toastActionError(error, 'Failed to save')
72
+ * onError: ({ error }) => toastActionError(error, 'Failed to save')
73
+ */
74
+ export function toastActionError(
75
+ error: unknown,
76
+ fallback = DEFAULT_ERROR
77
+ ): void {
78
+ if (typeof error === 'string' && error.trim()) {
79
+ toastErrorMessage(error)
80
+ return
81
+ }
82
+
83
+ if (error && typeof error === 'object') {
84
+ const bag = error as ActionResultLike & { message?: string }
85
+
86
+ if (typeof bag.serverError === 'string' && bag.serverError) {
87
+ toastErrorMessage(bag.serverError)
88
+ return
89
+ }
90
+ if (bag.validationErrors) {
91
+ toastErrorMessage('Check the highlighted fields and try again.')
92
+ return
93
+ }
94
+ if (typeof bag.message === 'string' && bag.message.trim()) {
95
+ toastErrorMessage(bag.message)
96
+ return
97
+ }
98
+ }
99
+
100
+ toastErrorMessage(fallback)
101
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Shared Zod fragments for action input and forms.
3
+ *
4
+ * Client-safe: no env, DB, or server-only imports, so forms can reuse the
5
+ * exact schema their action validates with.
6
+ */
7
+ import z from 'zod'
8
+ import {
9
+ MAX_COMMENT_LENGTH,
10
+ MAX_IMAGE_URL_LENGTH,
11
+ MAX_MESSAGE_LENGTH,
12
+ MAX_RATING,
13
+ MAX_SLUG_LENGTH,
14
+ MIN_RATING
15
+ } from '~/lib/constants'
16
+ import { isStoredImageValue } from '~/lib/image-url'
17
+
18
+ /**
19
+ * Phone number: 7 to 15 digits once separators are removed (E.164 maximum).
20
+ * Allowed characters are digits, `+`, `-`, `()`, `.`, and spaces.
21
+ */
22
+ export const phoneValidation = z
23
+ .string()
24
+ .trim()
25
+ .max(20)
26
+ .regex(/^[\d+\-().\s]+$/, 'Enter a valid phone number.')
27
+ .refine(value => {
28
+ const digits = value.replace(/\D/g, '').length
29
+ return digits >= 7 && digits <= 15
30
+ }, 'Enter a phone number with 7 to 15 digits.')
31
+
32
+ /** Email address, trimmed and lowercased. */
33
+ export const emailValidation = z.email().trim().toLowerCase()
34
+
35
+ /** Required short string, 1 to 255 characters after trimming. */
36
+ export const stringValidation = z.string().trim().min(1).max(255)
37
+
38
+ /** Optional short string (max 255). Empty string is allowed after trim. */
39
+ export const optionalStringValidation = z.string().trim().max(255).optional()
40
+
41
+ /** Required body for support tickets and similar messages. */
42
+ export const requiredMessageValidation = z
43
+ .string()
44
+ .trim()
45
+ .min(1, 'Enter a message.')
46
+ .max(MAX_MESSAGE_LENGTH)
47
+
48
+ /** Required feedback comment. */
49
+ export const requiredCommentValidation = z
50
+ .string()
51
+ .trim()
52
+ .min(1, 'Write your feedback.')
53
+ .max(MAX_COMMENT_LENGTH)
54
+
55
+ /** Star rating from 1 to 5. */
56
+ export const ratingValidation = z.number().int().min(MIN_RATING).max(MAX_RATING)
57
+
58
+ /**
59
+ * R2 object key or `data:image/*;base64,...` URL. Empty after trim is allowed
60
+ * so a cleared field still parses; the action treats it as "no image".
61
+ */
62
+ export const imageUrlValidation = z
63
+ .string()
64
+ .trim()
65
+ .max(MAX_IMAGE_URL_LENGTH)
66
+ .refine(value => value === '' || isStoredImageValue(value), {
67
+ message: 'Upload an image file. External URLs are not stored.'
68
+ })
69
+
70
+ /**
71
+ * URL slug: lowercase letters, numbers, and single hyphens between segments.
72
+ * Rejects empty strings, path segments, and query characters.
73
+ */
74
+ export const slugValidation = z
75
+ .string()
76
+ .trim()
77
+ .toLowerCase()
78
+ .min(1, 'Enter a slug.')
79
+ .max(MAX_SLUG_LENGTH)
80
+ .regex(
81
+ /^[a-z0-9]+(?:-[a-z0-9]+)*$/,
82
+ 'Use only lowercase letters, numbers, and hyphens.'
83
+ )
84
+
85
+ /**
86
+ * Build a URL slug from a display name.
87
+ * Returns an empty string when nothing remains, such as an emoji-only name.
88
+ */
89
+ export function slugifyName(name: string): string {
90
+ return name
91
+ .toLowerCase()
92
+ .replace(/[^a-z0-9]+/g, '-')
93
+ .replace(/(^-|-$)/g, '')
94
+ .slice(0, MAX_SLUG_LENGTH)
95
+ }
@@ -0,0 +1,48 @@
1
+ import { type NextRequest, NextResponse } from 'next/server'
2
+
3
+ /**
4
+ * Proxy middleware function
5
+ *
6
+ * Forwards the request pathname (+ search) as `x-pathname` so server layouts
7
+ * can build a same-origin login callback without losing deep links.
8
+ * @returns NextResponse that continues the request chain
9
+ */
10
+ export function proxy(request: NextRequest) {
11
+ const requestHeaders = new Headers(request.headers)
12
+ requestHeaders.set(
13
+ 'x-pathname',
14
+ `${request.nextUrl.pathname}${request.nextUrl.search}`
15
+ )
16
+
17
+ return NextResponse.next({
18
+ request: { headers: requestHeaders }
19
+ })
20
+ }
21
+
22
+ /**
23
+ * Middleware configuration
24
+ *
25
+ * Defines which routes this middleware should be applied to using the matcher pattern.
26
+ */
27
+ export const config = {
28
+ matcher: [
29
+ {
30
+ /**
31
+ * Route matching pattern
32
+ *
33
+ * This regex pattern matches all routes EXCEPT:
34
+ * - /_next/static - Next.js static assets
35
+ * - /_next/image - Next.js image optimization
36
+ * - /favicon.ico - Browser favicon
37
+ * - /.well-known/workflow/ - Internal workflow paths
38
+ *
39
+ * Excluding these paths ensures that:
40
+ * 1. Static assets load efficiently without middleware overhead
41
+ * 2. Image optimization is not interrupted
42
+ * 3. Workflow execution and resumption function correctly
43
+ */
44
+ source:
45
+ '/((?!_next/static|_next/image|favicon.ico|.well-known/workflow/).*)'
46
+ }
47
+ ]
48
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * @fileoverview Deletes uploads that were never confirmed, such as a logo
3
+ * picked and then abandoned. Run it on a schedule (for example hourly) with
4
+ * `bun run storage:cleanup`.
5
+ */
6
+
7
+ import { cleanupTempUploads, isR2Configured } from '~/lib/storage'
8
+
9
+ if (import.meta.main) {
10
+ if (!isR2Configured()) {
11
+ console.info('R2 is not configured; there are no temporary uploads.')
12
+ process.exit(0)
13
+ }
14
+
15
+ cleanupTempUploads()
16
+ .then(({ deleted, errors }) => {
17
+ console.info(`Deleted ${deleted} temporary uploads; ${errors} failed.`)
18
+ process.exit(errors ? 1 : 0)
19
+ })
20
+ .catch(err => {
21
+ console.error('Error cleaning up temporary uploads:', err)
22
+ process.exit(1)
23
+ })
24
+ }
@@ -0,0 +1,22 @@
1
+ import { sql } from 'drizzle-orm'
2
+ import { db } from '~/db'
3
+ import { env } from '~/env'
4
+
5
+ const resetDb = async () => {
6
+ if (env.APP_ENV === 'production') {
7
+ throw new Error('Cannot reset database in production environment.')
8
+ }
9
+
10
+ await db.execute(sql`DROP SCHEMA IF EXISTS public CASCADE;`)
11
+ await db.execute(sql`CREATE SCHEMA public;`)
12
+ }
13
+
14
+ if (import.meta.main) {
15
+ try {
16
+ await resetDb()
17
+ process.exit(0)
18
+ } catch (err) {
19
+ console.error('Error running reset-db:', err)
20
+ process.exit(1)
21
+ }
22
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * @fileoverview Seeds a local database with a superadmin, a regular user,
3
+ * and one organization on the free plan.
4
+ *
5
+ * Sign in with a magic link sent to either seeded address, so point them at
6
+ * inboxes you control. A successful run writes a `seed_runs` marker and later
7
+ * runs do nothing; reset the database to seed again. Everything runs in one
8
+ * transaction, so a failure leaves no partial data.
9
+ */
10
+
11
+ import { db } from '~/db'
12
+ import {
13
+ SEED_RUN_MARKER_ID,
14
+ membersTable,
15
+ organizationSubscriptionsTable,
16
+ organizationsTable,
17
+ seedRunsTable,
18
+ usersTable
19
+ } from '~/db/schema'
20
+
21
+ /** Seeds `database` once. Returns `skipped: true` when a seed already ran. */
22
+ export async function seedDatabase(
23
+ database: typeof db = db
24
+ ): Promise<{ skipped: boolean }> {
25
+ const [existingSeedRun] = await database
26
+ .select({ id: seedRunsTable.id })
27
+ .from(seedRunsTable)
28
+ .limit(1)
29
+
30
+ if (existingSeedRun) return { skipped: true }
31
+
32
+ await database.transaction(async tx => {
33
+ const superadminId = usersTable.id.defaultFn!()
34
+ const userId = usersTable.id.defaultFn!()
35
+ const organizationId = organizationsTable.id.defaultFn!()
36
+
37
+ await tx.insert(usersTable).values([
38
+ {
39
+ id: superadminId,
40
+ name: 'Super Admin',
41
+ email: 'superadmin@example.com',
42
+ emailVerified: true,
43
+ role: 'superadmin'
44
+ },
45
+ {
46
+ id: userId,
47
+ name: 'Demo User',
48
+ email: 'user@example.com',
49
+ emailVerified: true,
50
+ role: 'user'
51
+ }
52
+ ])
53
+
54
+ await tx.insert(organizationsTable).values({
55
+ id: organizationId,
56
+ name: 'Demo Organization',
57
+ slug: 'demo-organization'
58
+ })
59
+
60
+ await tx.insert(membersTable).values({
61
+ organizationId,
62
+ userId,
63
+ role: 'owner'
64
+ })
65
+
66
+ await tx.insert(organizationSubscriptionsTable).values({ organizationId })
67
+
68
+ // Marker last, so a failed seed never blocks a retry.
69
+ await tx.insert(seedRunsTable).values({ id: SEED_RUN_MARKER_ID })
70
+ })
71
+
72
+ return { skipped: false }
73
+ }
74
+
75
+ if (import.meta.main) {
76
+ seedDatabase(db)
77
+ .then(result => {
78
+ if (result.skipped) console.info('Database already seeded; skipping.')
79
+ process.exit(0)
80
+ })
81
+ .catch(err => {
82
+ console.error('Error running seed:', err)
83
+ process.exit(1)
84
+ })
85
+ }