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,72 @@
1
+ /**
2
+ * @fileoverview Installs Postgres functions and triggers that Drizzle cannot
3
+ * manage.
4
+ *
5
+ * Support tickets get a `TICKET-000001` number from a counter table. The
6
+ * counter function upserts atomically, and a BEFORE INSERT trigger fills in
7
+ * `ticket_number`, so inserts never pass the number themselves.
8
+ *
9
+ * Everything is CREATE OR REPLACE, so the script is safe to re-run.
10
+ * `bun run db:push` runs it before `drizzle-kit push`.
11
+ */
12
+
13
+ import { sql } from 'drizzle-orm'
14
+ import { db } from '~/db'
15
+
16
+ /** Installs every custom function and trigger on the given client. */
17
+ export async function setupDatabase(database: typeof db = db): Promise<void> {
18
+ // INSERT ... ON CONFLICT ... DO UPDATE is one atomic statement, so two
19
+ // concurrent calls never receive the same number.
20
+ await database.execute(sql`
21
+ CREATE OR REPLACE FUNCTION get_next_support_ticket_number()
22
+ RETURNS TEXT AS $$
23
+ DECLARE
24
+ new_number INTEGER;
25
+ BEGIN
26
+ INSERT INTO support_ticket_counter (id, counter)
27
+ VALUES (1, 1)
28
+ ON CONFLICT (id)
29
+ DO UPDATE SET counter = support_ticket_counter.counter + 1
30
+ RETURNING counter INTO new_number;
31
+
32
+ RETURN 'TICKET-' || LPAD(new_number::TEXT, 6, '0');
33
+ END;
34
+ $$ LANGUAGE plpgsql;
35
+ `)
36
+
37
+ // An explicit ticket_number (seeding, migrations) skips the counter.
38
+ await database.execute(sql`
39
+ CREATE OR REPLACE FUNCTION trg_set_support_ticket_number()
40
+ RETURNS TRIGGER AS $$
41
+ BEGIN
42
+ IF NEW.ticket_number IS NULL OR NEW.ticket_number = '' THEN
43
+ NEW.ticket_number := get_next_support_ticket_number();
44
+ END IF;
45
+ RETURN NEW;
46
+ END;
47
+ $$ LANGUAGE plpgsql;
48
+ `)
49
+
50
+ // On a fresh database this runs before drizzle-kit push, so create a stub
51
+ // table for the trigger to attach to. The push then adds the real columns.
52
+ await database.execute(sql`
53
+ CREATE TABLE IF NOT EXISTS support_tickets (
54
+ id TEXT PRIMARY KEY
55
+ );
56
+ `)
57
+
58
+ await database.execute(sql`
59
+ CREATE OR REPLACE TRIGGER set_support_ticket_number
60
+ BEFORE INSERT ON support_tickets
61
+ FOR EACH ROW EXECUTE FUNCTION trg_set_support_ticket_number();
62
+ `)
63
+ }
64
+
65
+ if (import.meta.main) {
66
+ setupDatabase(db)
67
+ .then(() => process.exit(0))
68
+ .catch(err => {
69
+ console.error('Error running setup-db:', err)
70
+ process.exit(1)
71
+ })
72
+ }
@@ -0,0 +1,206 @@
1
+ /**
2
+ * @fileoverview Creates the Stripe products, prices, and billing portal
3
+ * configuration for every paid plan in `~/lib/plans`.
4
+ *
5
+ * Existing prices are reused. New IDs are written to `stripe.txt`; copy them
6
+ * into `.env`, then delete the file.
7
+ */
8
+ import { writeFile } from 'fs/promises'
9
+ import type Stripe from 'stripe'
10
+ import { env } from '~/env'
11
+ import { PLANS } from '~/lib/plans'
12
+ import { stripeClient } from '~/lib/stripe'
13
+
14
+ const checkStripePriceExists = async (priceId: string) => {
15
+ try {
16
+ await stripeClient.prices.retrieve(priceId)
17
+ return true
18
+ } catch (err) {
19
+ if (
20
+ err &&
21
+ typeof err === 'object' &&
22
+ 'code' in err &&
23
+ (err as { code?: string }).code === 'resource_missing'
24
+ ) {
25
+ return false
26
+ }
27
+ throw err
28
+ }
29
+ }
30
+
31
+ const createStripeProduct = async (name: string, planId: string) => {
32
+ const newProduct = await stripeClient.products.create({
33
+ name,
34
+ metadata: { planId }
35
+ })
36
+
37
+ return newProduct.id
38
+ }
39
+
40
+ const createStripePrice = async (
41
+ productId: string,
42
+ planId: string,
43
+ interval: 'month' | 'year',
44
+ amount: number,
45
+ currency: string
46
+ ) => {
47
+ const newPrice = await stripeClient.prices.create({
48
+ product: productId,
49
+ currency,
50
+ unit_amount: amount,
51
+ recurring: { interval },
52
+ metadata: { planId }
53
+ })
54
+
55
+ return newPrice.id
56
+ }
57
+
58
+ type PaidPlanPrices = {
59
+ planId: string
60
+ productId: string
61
+ monthlyPriceId: string
62
+ annualPriceId: string
63
+ }
64
+
65
+ const setupStripePlans = async () => {
66
+ let newEnvString = ''
67
+ const paidPlans: PaidPlanPrices[] = []
68
+
69
+ for (const plan of PLANS) {
70
+ if (!plan.stripePrice) continue
71
+
72
+ const monthlyExists = await checkStripePriceExists(
73
+ plan.stripePrice.monthly.id
74
+ )
75
+ const annualExists = await checkStripePriceExists(
76
+ plan.stripePrice.annual.id
77
+ )
78
+
79
+ if ((!monthlyExists && annualExists) || (monthlyExists && !annualExists)) {
80
+ throw new Error(
81
+ `Inconsistent Stripe price setup for plan ${plan.name}. Monthly exists: ${monthlyExists}, Annual exists: ${annualExists}`
82
+ )
83
+ }
84
+
85
+ if (monthlyExists && annualExists) {
86
+ const price = await stripeClient.prices.retrieve(
87
+ plan.stripePrice.monthly.id
88
+ )
89
+ paidPlans.push({
90
+ planId: plan.id,
91
+ productId:
92
+ typeof price.product === 'string' ? price.product : price.product.id,
93
+ monthlyPriceId: plan.stripePrice.monthly.id,
94
+ annualPriceId: plan.stripePrice.annual.id
95
+ })
96
+ continue
97
+ }
98
+
99
+ const productId = await createStripeProduct(plan.name, plan.id)
100
+ const monthlyPriceId = await createStripePrice(
101
+ productId,
102
+ plan.id,
103
+ 'month',
104
+ plan.stripePrice.monthly.amount,
105
+ plan.stripePrice.monthly.currency
106
+ )
107
+
108
+ newEnvString += `\nSTRIPE_PRICE_${plan.id.toUpperCase()}_MONTHLY="${monthlyPriceId}"`
109
+
110
+ const annualPriceId = await createStripePrice(
111
+ productId,
112
+ plan.id,
113
+ 'year',
114
+ plan.stripePrice.annual.amount,
115
+ plan.stripePrice.annual.currency
116
+ )
117
+
118
+ newEnvString += `\nSTRIPE_PRICE_${plan.id.toUpperCase()}_ANNUAL="${annualPriceId}"`
119
+
120
+ paidPlans.push({
121
+ planId: plan.id,
122
+ productId,
123
+ monthlyPriceId,
124
+ annualPriceId
125
+ })
126
+ }
127
+
128
+ const portalConfigId = await setupPortalConfiguration(paidPlans)
129
+ if (portalConfigId) {
130
+ newEnvString += `\nSTRIPE_PORTAL_CONFIGURATION_ID="${portalConfigId}"`
131
+ }
132
+
133
+ newEnvString = newEnvString.trim()
134
+
135
+ if (newEnvString) {
136
+ await writeFile('stripe.txt', newEnvString)
137
+ console.info('Stripe setup complete. New values written to stripe.txt')
138
+ } else {
139
+ console.info('All Stripe plans and portal config already exist.')
140
+ }
141
+ }
142
+
143
+ const setupPortalConfiguration = async (paidPlans: PaidPlanPrices[]) => {
144
+ if (paidPlans.length === 0) return null
145
+
146
+ const params: Stripe.BillingPortal.ConfigurationCreateParams = {
147
+ features: {
148
+ customer_update: {
149
+ enabled: true,
150
+ allowed_updates: ['address', 'name', 'tax_id']
151
+ },
152
+ invoice_history: { enabled: true },
153
+ payment_method_update: { enabled: true },
154
+ subscription_cancel: {
155
+ enabled: true,
156
+ mode: 'at_period_end',
157
+ cancellation_reason: {
158
+ enabled: true,
159
+ options: [
160
+ 'too_expensive',
161
+ 'missing_features',
162
+ 'switched_service',
163
+ 'unused',
164
+ 'other'
165
+ ]
166
+ }
167
+ },
168
+ subscription_update: {
169
+ enabled: true,
170
+ default_allowed_updates: ['price'],
171
+ proration_behavior: 'create_prorations',
172
+ products: paidPlans.map(p => ({
173
+ product: p.productId,
174
+ prices: [p.monthlyPriceId, p.annualPriceId]
175
+ }))
176
+ }
177
+ }
178
+ }
179
+
180
+ const existingId = env.STRIPE_PORTAL_CONFIGURATION_ID
181
+ if (existingId) {
182
+ try {
183
+ await stripeClient.billingPortal.configurations.update(existingId, params)
184
+ return null
185
+ } catch (err) {
186
+ const isMissing =
187
+ err &&
188
+ typeof err === 'object' &&
189
+ 'code' in err &&
190
+ (err as { code?: string }).code === 'resource_missing'
191
+ if (!isMissing) throw err
192
+ }
193
+ }
194
+
195
+ const created = await stripeClient.billingPortal.configurations.create(params)
196
+ return created.id
197
+ }
198
+
199
+ if (import.meta.main) {
200
+ setupStripePlans()
201
+ .then(() => process.exit(0))
202
+ .catch(err => {
203
+ console.error('Error setting up Stripe plans:', err)
204
+ process.exit(1)
205
+ })
206
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * @fileoverview Sends one test email to check SMTP settings.
3
+ *
4
+ * @example
5
+ * ```bash
6
+ * bun run email:test you@example.com
7
+ * ```
8
+ */
9
+
10
+ import { render, toPlainText } from '@react-email/render'
11
+ import TestEmail from '~/emails/test'
12
+ import { sendEmail } from '~/lib/nodemailer'
13
+
14
+ async function sendTestMail(to: string) {
15
+ const html = await render(TestEmail({ name: to }), { pretty: true })
16
+
17
+ const result = await sendEmail({
18
+ to,
19
+ subject: 'Test Email',
20
+ text: toPlainText(html),
21
+ html
22
+ })
23
+
24
+ console.info('Test email sent', result)
25
+ }
26
+
27
+ if (import.meta.main) {
28
+ const to = process.argv[2]
29
+
30
+ if (!to) {
31
+ console.error(
32
+ 'Pass a recipient address: bun run email:test you@example.com'
33
+ )
34
+ process.exit(1)
35
+ }
36
+
37
+ sendTestMail(to)
38
+ .then(() => process.exit(0))
39
+ .catch(err => {
40
+ console.error('Error sending test mail:', err)
41
+ process.exit(1)
42
+ })
43
+ }
@@ -0,0 +1,64 @@
1
+ import { afterEach, describe, test } from 'bun:test'
2
+ import { createUser, getUsers, updateUser } from '~/app/(admin)/actions/users'
3
+ import {
4
+ asAnonymous,
5
+ asUser,
6
+ expectData,
7
+ expectServerError,
8
+ Fixtures
9
+ } from '../helpers'
10
+
11
+ describe('admin user management', () => {
12
+ const fx = new Fixtures()
13
+ afterEach(() => fx.cleanup())
14
+
15
+ test('signed-out and regular users cannot reach admin actions', async () => {
16
+ const user = await fx.user()
17
+
18
+ expectServerError(
19
+ await asAnonymous(() => getUsers({ page: 1, limit: 10 })),
20
+ 'sign in'
21
+ )
22
+ expectServerError(
23
+ await asUser(user, () => getUsers({ page: 1, limit: 10 })),
24
+ 'permission'
25
+ )
26
+ })
27
+
28
+ test('a superadmin cannot remove their own superadmin role', async () => {
29
+ const superadmin = await fx.platformUser('superadmin')
30
+
31
+ const result = await asUser(superadmin, () =>
32
+ updateUser({
33
+ id: superadmin.id,
34
+ name: superadmin.name,
35
+ email: superadmin.email,
36
+ role: 'admin'
37
+ })
38
+ )
39
+
40
+ expectServerError(result, 'your own superadmin role')
41
+ })
42
+
43
+ test('admins cannot manage users; superadmins can', async () => {
44
+ const admin = await fx.platformUser('admin')
45
+ const superadmin = await fx.platformUser('superadmin')
46
+
47
+ expectServerError(
48
+ await asUser(admin, () => getUsers({ page: 1, limit: 10 })),
49
+ 'permission'
50
+ )
51
+ expectData(await asUser(superadmin, () => getUsers({ page: 1, limit: 10 })))
52
+ })
53
+
54
+ test('creating a user with a taken email explains the conflict', async () => {
55
+ const superadmin = await fx.platformUser('superadmin')
56
+ const existing = await fx.user()
57
+
58
+ const result = await asUser(superadmin, () =>
59
+ createUser({ name: 'Duplicate', email: existing.email, role: 'user' })
60
+ )
61
+
62
+ expectServerError(result, 'already uses this email')
63
+ })
64
+ })
@@ -0,0 +1,62 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { sql } from 'drizzle-orm'
3
+ import { db } from '~/db'
4
+
5
+ /**
6
+ * Postgres does not index foreign key columns automatically. Without an index,
7
+ * every cascade or SET NULL from the parent side sequentially scans the child
8
+ * table while holding row locks, so deleting one organization can stall the
9
+ * platform.
10
+ *
11
+ * This finds every FK whose leading column is not the leading column of any
12
+ * index, which is exactly the shape that degrades.
13
+ */
14
+ const UNINDEXED_FOREIGN_KEYS = sql`
15
+ SELECT
16
+ c.conrelid::regclass::text AS table_name,
17
+ a.attname::text AS column_name
18
+ FROM pg_constraint c
19
+ JOIN pg_attribute a
20
+ ON a.attrelid = c.conrelid AND a.attnum = c.conkey[1]
21
+ WHERE c.contype = 'f'
22
+ AND NOT EXISTS (
23
+ SELECT 1
24
+ FROM pg_index i
25
+ WHERE i.indrelid = c.conrelid
26
+ AND i.indkey[0] = c.conkey[1]
27
+ )
28
+ ORDER BY 1, 2
29
+ `
30
+
31
+ /** postgres-js returns a row array; PGlite returns `{ rows }`. */
32
+ const toRows = <T>(result: unknown): T[] =>
33
+ Array.isArray(result)
34
+ ? (result as T[])
35
+ : ((result as { rows: T[] }).rows ?? [])
36
+
37
+ describe('foreign key indexing', () => {
38
+ test('every foreign key column leads an index', async () => {
39
+ const rows = toRows<{ table_name: string; column_name: string }>(
40
+ await db.execute(UNINDEXED_FOREIGN_KEYS)
41
+ )
42
+
43
+ expect(rows.map(r => `${r.table_name}.${r.column_name}`)).toEqual([])
44
+ })
45
+
46
+ test('every foreign key declares an explicit delete rule', async () => {
47
+ const rows = toRows<{ table_name: string; constraint_name: string }>(
48
+ await db.execute(sql`
49
+ SELECT
50
+ c.conrelid::regclass::text AS table_name,
51
+ c.conname::text AS constraint_name
52
+ FROM pg_constraint c
53
+ WHERE c.contype = 'f' AND c.confdeltype = 'a'
54
+ ORDER BY 1, 2
55
+ `)
56
+ )
57
+
58
+ // confdeltype 'a' is NO ACTION, which is what an omitted onDelete leaves
59
+ // behind. Every FK should say cascade, set null, or restrict on purpose.
60
+ expect(rows.map(r => `${r.table_name}.${r.constraint_name}`)).toEqual([])
61
+ })
62
+ })
@@ -0,0 +1,57 @@
1
+ import { PGlite } from '@electric-sql/pglite'
2
+ import { describe, expect, test } from 'bun:test'
3
+ import { pushSchema } from 'drizzle-kit/api'
4
+ import { drizzle } from 'drizzle-orm/pglite'
5
+ import * as relations from '~/db/relations'
6
+ import * as schema from '~/db/schema'
7
+ import { seedDatabase } from '~/scripts/seed'
8
+ import { setupDatabase } from '~/scripts/setup-db'
9
+
10
+ /**
11
+ * The seed writes straight through the schema, around Zod, so it exercises
12
+ * CHECK constraints, foreign keys, and triggers against realistic data.
13
+ *
14
+ * It runs on its own PGlite instance: seeded rows would otherwise leak into
15
+ * every other suite in the same worker and break their counts.
16
+ */
17
+ describe('seed script', () => {
18
+ test('applies cleanly once, then skips', async () => {
19
+ // Same Emscripten proc_exit side effect as tests/setup/pglite-db.ts.
20
+ const exitCodeBeforePglite = process.exitCode
21
+
22
+ const client = new PGlite()
23
+ const db = drizzle({ client, schema: { ...schema, ...relations } })
24
+
25
+ const { apply, hasDataLoss } = await pushSchema(
26
+ schema,
27
+ db as unknown as Parameters<typeof pushSchema>[1]
28
+ )
29
+ expect(hasDataLoss).toBe(false)
30
+ await apply()
31
+ await setupDatabase(db as unknown as Parameters<typeof setupDatabase>[0])
32
+
33
+ const seed = () =>
34
+ seedDatabase(db as unknown as Parameters<typeof seedDatabase>[0])
35
+
36
+ expect((await seed()).skipped).toBe(false)
37
+
38
+ const [users, organizations, subscriptions] = await Promise.all([
39
+ db.select().from(schema.usersTable),
40
+ db.select().from(schema.organizationsTable),
41
+ db.select().from(schema.organizationSubscriptionsTable)
42
+ ])
43
+ expect(users.map(user => user.role).sort()).toEqual(['superadmin', 'user'])
44
+ expect(organizations).toHaveLength(1)
45
+ expect(subscriptions).toHaveLength(1)
46
+
47
+ expect((await seed()).skipped).toBe(true)
48
+ expect(await db.select().from(schema.usersTable)).toHaveLength(2)
49
+
50
+ if (process.exitCode === 99) {
51
+ process.exitCode =
52
+ exitCodeBeforePglite !== undefined && exitCodeBeforePglite !== 99
53
+ ? exitCodeBeforePglite
54
+ : 0
55
+ }
56
+ }, 120_000)
57
+ })
@@ -0,0 +1,71 @@
1
+ import { expect } from 'bun:test'
2
+ import { UNEXPECTED_ERROR_MESSAGE } from '~/lib/safe-action'
3
+
4
+ type ActionResult = {
5
+ data?: unknown
6
+ serverError?: string
7
+ validationErrors?: unknown
8
+ }
9
+
10
+ const MASKED = UNEXPECTED_ERROR_MESSAGE
11
+
12
+ export function expectData<T>(
13
+ result: ActionResult | undefined
14
+ ): asserts result is { data: T } {
15
+ if (!result) throw new Error('Expected action result, got undefined')
16
+ if (result.serverError) {
17
+ throw new Error(`Expected success, got serverError: ${result.serverError}`)
18
+ }
19
+ if (result.validationErrors) {
20
+ throw new Error(
21
+ `Expected success, got validationErrors: ${JSON.stringify(result.validationErrors)}`
22
+ )
23
+ }
24
+ if (result.data === undefined) {
25
+ throw new Error('Expected action data, got undefined')
26
+ }
27
+ }
28
+
29
+ /** Action returned null/undefined data (some actions swallow errors). */
30
+ export function expectNoData(result: ActionResult | undefined) {
31
+ expect(result?.data ?? null).toBeNull()
32
+ }
33
+
34
+ /** Action was rejected by its input schema, before any handler ran. */
35
+ export function expectValidationError(result: ActionResult | undefined) {
36
+ if (!result?.validationErrors) {
37
+ throw new Error(
38
+ `Expected validationErrors, got: ${JSON.stringify(result ?? null)}`
39
+ )
40
+ }
41
+ return result.validationErrors
42
+ }
43
+
44
+ /**
45
+ * Action failed with a serverError.
46
+ * next-safe-action can mask thrown Error messages as a generic string.
47
+ * In that case, this helper only asserts that a failure occurred.
48
+ */
49
+ export function expectServerError(
50
+ result: ActionResult | undefined,
51
+ messageIncludes?: string | RegExp
52
+ ) {
53
+ if (!result?.serverError) {
54
+ throw new Error(
55
+ `Expected serverError, got: ${JSON.stringify(result ?? null)}`
56
+ )
57
+ }
58
+ if (
59
+ messageIncludes &&
60
+ result.serverError.toLowerCase() !== MASKED.toLowerCase()
61
+ ) {
62
+ if (typeof messageIncludes === 'string') {
63
+ expect(result.serverError.toLowerCase()).toContain(
64
+ messageIncludes.toLowerCase()
65
+ )
66
+ } else {
67
+ expect(result.serverError).toMatch(messageIncludes)
68
+ }
69
+ }
70
+ return result.serverError
71
+ }