create-ej-app 26.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 (357) hide show
  1. package/README.md +125 -226
  2. package/cli.ts +79 -29
  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 -1
  15. package/template/nextjs/next.config.ts +8 -1
  16. package/template/nextjs/package.json +46 -39
  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 +1 -0
  140. package/template/saas-starter/.env.example +15 -15
  141. package/template/saas-starter/AGENTS.md +302 -0
  142. package/template/saas-starter/CLAUDE.md +1 -0
  143. package/template/saas-starter/README.md +124 -2
  144. package/template/saas-starter/bunfig.toml +3 -0
  145. package/template/saas-starter/eslint.config.mjs +6 -5
  146. package/template/saas-starter/gitignore +6 -1
  147. package/template/saas-starter/next.config.ts +19 -1
  148. package/template/saas-starter/package.json +49 -44
  149. package/template/saas-starter/public/logo-dark.svg +4 -0
  150. package/template/saas-starter/public/logo.svg +4 -0
  151. package/template/saas-starter/src/app/(admin)/actions/contact-leads.ts +51 -0
  152. package/template/saas-starter/src/app/(admin)/actions/feedbacks.ts +91 -0
  153. package/template/saas-starter/src/app/(admin)/actions/users.ts +145 -59
  154. package/template/saas-starter/src/app/(admin)/admin/_components/admin-header.tsx +4 -1
  155. package/template/saas-starter/src/app/(admin)/admin/_components/admin-sidebar.tsx +12 -10
  156. package/template/saas-starter/src/app/(admin)/admin/contact-leads/page.tsx +123 -0
  157. package/template/saas-starter/src/app/(admin)/admin/feedbacks/page.tsx +161 -0
  158. package/template/saas-starter/src/app/(admin)/admin/layout.tsx +24 -28
  159. package/template/saas-starter/src/app/(admin)/admin/page.tsx +59 -2
  160. package/template/saas-starter/src/app/(admin)/admin/settings/layout.tsx +24 -0
  161. package/template/saas-starter/src/app/(admin)/admin/settings/users/[userId]/page.tsx +43 -0
  162. package/template/saas-starter/src/app/(admin)/admin/settings/users/new/page.tsx +16 -0
  163. package/template/saas-starter/src/app/(admin)/admin/settings/users/page.tsx +372 -10
  164. package/template/saas-starter/src/app/(admin)/admin/settings/users/user-form.tsx +138 -0
  165. package/template/saas-starter/src/app/(admin)/admin/settings/users/validation.ts +23 -0
  166. package/template/saas-starter/src/app/(admin)/admin/support-tickets/[ticketId]/page.tsx +17 -0
  167. package/template/saas-starter/src/app/(admin)/admin/support-tickets/page.tsx +5 -0
  168. package/template/saas-starter/src/app/(app)/actions/billing.ts +171 -107
  169. package/template/saas-starter/src/app/(app)/actions/feedbacks.ts +28 -0
  170. package/template/saas-starter/src/app/(app)/actions/feedbacks.validation.ts +12 -0
  171. package/template/saas-starter/src/app/(app)/actions/members.ts +149 -123
  172. package/template/saas-starter/src/app/(app)/actions/organizations.ts +230 -0
  173. package/template/saas-starter/src/app/(app)/actions/organizations.validation.ts +17 -0
  174. package/template/saas-starter/src/app/(app)/app/[orgId]/dashboard/page.tsx +6 -9
  175. package/template/saas-starter/src/app/(app)/app/[orgId]/layout.tsx +6 -9
  176. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/billing-email-form.tsx +45 -53
  177. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/danger-zone.tsx +40 -47
  178. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/general-settings-form.tsx +80 -105
  179. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/plans-section.tsx +72 -82
  180. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/billing/page.tsx +51 -52
  181. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/layout.tsx +24 -0
  182. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/_components/invite-member-dialog.tsx +15 -16
  183. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/_components/members-tabs.tsx +77 -44
  184. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/page.tsx +11 -18
  185. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/page.tsx +14 -28
  186. package/template/saas-starter/src/app/(app)/app/[orgId]/support-tickets/[ticketId]/page.tsx +25 -0
  187. package/template/saas-starter/src/app/(app)/app/[orgId]/support-tickets/page.tsx +25 -0
  188. package/template/saas-starter/src/app/(app)/app/_components/app-header.tsx +15 -13
  189. package/template/saas-starter/src/app/(app)/app/_components/app-search.tsx +15 -6
  190. package/template/saas-starter/src/app/(app)/app/_components/app-sidebar.tsx +6 -3
  191. package/template/saas-starter/src/app/(app)/app/_components/feedback-dialog.tsx +108 -0
  192. package/template/saas-starter/src/app/(app)/app/_components/organization-context.tsx +5 -9
  193. package/template/saas-starter/src/app/(app)/app/_components/organization-switcher.tsx +68 -8
  194. package/template/saas-starter/src/app/(app)/app/layout.tsx +44 -32
  195. package/template/saas-starter/src/app/(app)/app/onboarding/_components/create-organization-form.tsx +75 -87
  196. package/template/saas-starter/src/app/(app)/app/onboarding/page.tsx +10 -12
  197. package/template/saas-starter/src/app/(app)/app/page.tsx +16 -21
  198. package/template/saas-starter/src/app/(auth)/(account)/layout.tsx +64 -0
  199. package/template/saas-starter/src/app/(auth)/(account)/profile/page.tsx +16 -0
  200. package/template/saas-starter/src/app/(auth)/(account)/profile/profile-form.tsx +247 -0
  201. package/template/saas-starter/src/app/(auth)/(account)/support/[ticketId]/page.tsx +19 -0
  202. package/template/saas-starter/src/app/(auth)/(account)/support/page.tsx +9 -0
  203. package/template/saas-starter/src/app/(auth)/login/email-login-form.tsx +49 -27
  204. package/template/saas-starter/src/app/(auth)/login/page.tsx +39 -21
  205. package/template/saas-starter/src/app/(auth)/login/social-login-buttons.tsx +80 -18
  206. package/template/saas-starter/src/app/(invitation)/accept-invitation/[id]/accept-invitation-form.tsx +16 -10
  207. package/template/saas-starter/src/app/(invitation)/accept-invitation/[id]/page.tsx +28 -42
  208. package/template/saas-starter/src/app/(invitation)/actions/invitations.ts +32 -0
  209. package/template/saas-starter/src/app/(marketing)/contact-us/actions.ts +23 -0
  210. package/template/saas-starter/src/app/(marketing)/contact-us/contact-form.tsx +118 -0
  211. package/template/saas-starter/src/app/(marketing)/contact-us/page.tsx +20 -0
  212. package/template/saas-starter/src/app/(marketing)/contact-us/validation.ts +22 -0
  213. package/template/saas-starter/src/app/(marketing)/layout.tsx +38 -4
  214. package/template/saas-starter/src/app/(marketing)/legal.tsx +66 -0
  215. package/template/saas-starter/src/app/(marketing)/page.tsx +23 -2
  216. package/template/saas-starter/src/app/(marketing)/privacy/page.tsx +62 -2
  217. package/template/saas-starter/src/app/(marketing)/terms/page.tsx +40 -2
  218. package/template/saas-starter/src/app/actions/support-tickets.ts +510 -0
  219. package/template/saas-starter/src/app/actions/support-tickets.validation.ts +112 -0
  220. package/template/saas-starter/src/app/actions/uploads.ts +36 -0
  221. package/template/saas-starter/src/app/actions/uploads.validation.ts +24 -0
  222. package/template/saas-starter/src/app/error.tsx +37 -0
  223. package/template/saas-starter/src/app/fonts/geist-mono/GeistMono-Variable.woff2 +0 -0
  224. package/template/saas-starter/src/app/fonts/geist-sans/Geist-Variable.woff2 +0 -0
  225. package/template/saas-starter/src/app/fonts/index.ts +60 -0
  226. package/template/saas-starter/src/app/global-error.tsx +38 -0
  227. package/template/saas-starter/src/app/globals.css +29 -0
  228. package/template/saas-starter/src/app/layout.tsx +43 -19
  229. package/template/saas-starter/src/app/not-found.tsx +25 -3
  230. package/template/saas-starter/src/app/robots.ts +14 -10
  231. package/template/saas-starter/src/app/sitemap.ts +2 -1
  232. package/template/saas-starter/src/components/confirm-dialog.tsx +79 -0
  233. package/template/saas-starter/src/components/dashboard-header.tsx +7 -1
  234. package/template/saas-starter/src/components/data-table/features.ts +49 -0
  235. package/template/saas-starter/src/components/data-table/index.tsx +65 -64
  236. package/template/saas-starter/src/components/data-table/types.ts +5 -4
  237. package/template/saas-starter/src/components/error.tsx +45 -0
  238. package/template/saas-starter/src/components/file-upload.tsx +65 -26
  239. package/template/saas-starter/src/components/loader.tsx +18 -2
  240. package/template/saas-starter/src/components/logo.tsx +49 -0
  241. package/template/saas-starter/src/components/navigation-search.tsx +2 -2
  242. package/template/saas-starter/src/components/page-heading.tsx +6 -0
  243. package/template/saas-starter/src/components/react-query-provider.tsx +41 -2
  244. package/template/saas-starter/src/components/route-error.tsx +55 -0
  245. package/template/saas-starter/src/components/screen-size.tsx +36 -0
  246. package/template/saas-starter/src/components/site-footer.tsx +20 -4
  247. package/template/saas-starter/src/components/support/create-support-ticket-dialog.tsx +122 -0
  248. package/template/saas-starter/src/components/support/support-ticket-conversation.tsx +236 -0
  249. package/template/saas-starter/src/components/support/support-ticket-shared.tsx +58 -0
  250. package/template/saas-starter/src/components/support/support-tickets-table.tsx +281 -0
  251. package/template/saas-starter/src/components/theme-provider.tsx +48 -0
  252. package/template/saas-starter/src/components/theme-toggle.tsx +58 -5
  253. package/template/saas-starter/src/components/ui/alert-dialog.tsx +199 -0
  254. package/template/saas-starter/src/components/ui/badge.tsx +6 -1
  255. package/template/saas-starter/src/components/ui/button.tsx +2 -0
  256. package/template/saas-starter/src/components/ui/command.tsx +1 -1
  257. package/template/saas-starter/src/components/user-menu.tsx +147 -24
  258. package/template/saas-starter/src/db/common.ts +33 -1
  259. package/template/saas-starter/src/db/enums.ts +24 -0
  260. package/template/saas-starter/src/db/index.ts +14 -5
  261. package/template/saas-starter/src/db/relations.ts +40 -3
  262. package/template/saas-starter/src/db/schema.ts +186 -35
  263. package/template/saas-starter/src/emails/components/email-layout.tsx +27 -11
  264. package/template/saas-starter/src/emails/invitation.tsx +34 -21
  265. package/template/saas-starter/src/emails/magic-link.tsx +15 -13
  266. package/template/saas-starter/src/emails/test.tsx +11 -9
  267. package/template/saas-starter/src/emails/welcome.tsx +12 -9
  268. package/template/saas-starter/src/env.ts +1 -1
  269. package/template/saas-starter/src/hooks/use-mobile.ts +14 -12
  270. package/template/saas-starter/src/instrumentation.ts +20 -2
  271. package/template/saas-starter/src/lib/actions.ts +34 -0
  272. package/template/saas-starter/src/lib/admin-navigation.ts +33 -1
  273. package/template/saas-starter/src/lib/app-navigation.ts +45 -41
  274. package/template/saas-starter/src/lib/auth-client.ts +8 -3
  275. package/template/saas-starter/src/lib/auth-permissions.ts +50 -11
  276. package/template/saas-starter/src/lib/auth.ts +122 -21
  277. package/template/saas-starter/src/lib/billing.ts +59 -24
  278. package/template/saas-starter/src/lib/callback-url.ts +28 -0
  279. package/template/saas-starter/src/lib/constants.ts +78 -9
  280. package/template/saas-starter/src/lib/delay.ts +10 -0
  281. package/template/saas-starter/src/lib/email-service.ts +30 -1
  282. package/template/saas-starter/src/lib/errors.ts +74 -0
  283. package/template/saas-starter/src/lib/format-currency.ts +22 -24
  284. package/template/saas-starter/src/lib/format-date.ts +69 -6
  285. package/template/saas-starter/src/lib/image-url.ts +54 -0
  286. package/template/saas-starter/src/lib/nodemailer.ts +27 -3
  287. package/template/saas-starter/src/lib/organization-access.ts +56 -76
  288. package/template/saas-starter/src/lib/otel.ts +92 -2
  289. package/template/saas-starter/src/lib/plan-limits.ts +37 -0
  290. package/template/saas-starter/src/lib/plans.ts +40 -13
  291. package/template/saas-starter/src/lib/rbac.ts +44 -0
  292. package/template/saas-starter/src/lib/safe-action-client.ts +60 -9
  293. package/template/saas-starter/src/lib/safe-action.ts +137 -17
  294. package/template/saas-starter/src/lib/search.ts +16 -0
  295. package/template/saas-starter/src/lib/seo.ts +7 -0
  296. package/template/saas-starter/src/lib/siteConfig.ts +17 -1
  297. package/template/saas-starter/src/lib/storage.ts +176 -46
  298. package/template/saas-starter/src/lib/stripe.ts +4 -1
  299. package/template/saas-starter/src/lib/toast-message.ts +87 -1
  300. package/template/saas-starter/src/lib/validations.ts +85 -2
  301. package/template/saas-starter/src/proxy.ts +48 -0
  302. package/template/saas-starter/src/scripts/cleanup-temp-uploads.ts +24 -0
  303. package/template/saas-starter/src/scripts/seed.ts +82 -8
  304. package/template/saas-starter/src/scripts/setup-db.ts +72 -0
  305. package/template/saas-starter/src/scripts/setup-stripe-plans.ts +13 -7
  306. package/template/saas-starter/src/scripts/test-mail.ts +28 -20
  307. package/template/saas-starter/tests/admin/users.test.ts +64 -0
  308. package/template/saas-starter/tests/db/fk-indexes.test.ts +62 -0
  309. package/template/saas-starter/tests/db/seed.test.ts +57 -0
  310. package/template/saas-starter/tests/helpers/action.ts +71 -0
  311. package/template/saas-starter/tests/helpers/fixtures.ts +151 -0
  312. package/template/saas-starter/tests/helpers/index.ts +2 -0
  313. package/template/saas-starter/tests/lib/callback-url.test.ts +48 -0
  314. package/template/saas-starter/tests/lib/client-server-boundary.test.ts +57 -0
  315. package/template/saas-starter/tests/lib/errors.test.ts +10 -0
  316. package/template/saas-starter/tests/lib/image-url.test.ts +31 -0
  317. package/template/saas-starter/tests/lib/search.test.ts +23 -0
  318. package/template/saas-starter/tests/marketing/contact-and-feedback.test.ts +86 -0
  319. package/template/saas-starter/tests/organizations/organizations.test.ts +175 -0
  320. package/template/saas-starter/tests/setup/pglite-db.ts +67 -0
  321. package/template/saas-starter/tests/setup/preload.ts +108 -0
  322. package/template/saas-starter/tests/support/support-tickets.test.ts +145 -0
  323. package/template/saas-starter/tsconfig.json +1 -0
  324. package/cli.js +0 -147
  325. package/template/nextjs/src/app/(admin)/layout.tsx +0 -20
  326. package/template/nextjs/src/app/(app)/layout.tsx +0 -20
  327. package/template/nextjs/terraform/artifact-registry.tf +0 -5
  328. package/template/nextjs/terraform/backend-service.tf +0 -13
  329. package/template/nextjs/terraform/cleanup.sh +0 -9
  330. package/template/nextjs/terraform/cloudbuild.tf +0 -70
  331. package/template/nextjs/terraform/cloudrun.tf +0 -68
  332. package/template/nextjs/terraform/database.tf +0 -60
  333. package/template/nextjs/terraform/forwarding-rule.tf +0 -43
  334. package/template/nextjs/terraform/gitignore +0 -4
  335. package/template/nextjs/terraform/ip-address.tf +0 -11
  336. package/template/nextjs/terraform/network-endpoint-group.tf +0 -11
  337. package/template/nextjs/terraform/network.tf +0 -21
  338. package/template/nextjs/terraform/pre.sh +0 -3
  339. package/template/nextjs/terraform/providers.tf +0 -18
  340. package/template/nextjs/terraform/secret.txt +0 -11
  341. package/template/nextjs/terraform/setup.sh +0 -25
  342. package/template/nextjs/terraform/ssl-certs.tf +0 -32
  343. package/template/nextjs/terraform/target-https-proxy copy.tf +0 -14
  344. package/template/nextjs/terraform/target-https-proxy.tf +0 -14
  345. package/template/nextjs/terraform/terraform.tfvars +0 -4
  346. package/template/nextjs/terraform/url-map.tf +0 -23
  347. package/template/nextjs/terraform/variables.tf +0 -21
  348. package/template/saas-starter/next-env.d.ts +0 -6
  349. package/template/saas-starter/src/actions/uploads.ts +0 -28
  350. package/template/saas-starter/src/actions/uploads.validation.ts +0 -14
  351. package/template/saas-starter/src/app/(admin)/admin/settings/users/_components/create-user-dialog.tsx +0 -183
  352. package/template/saas-starter/src/app/(admin)/admin/settings/users/_components/edit-name-dialog.tsx +0 -129
  353. package/template/saas-starter/src/app/(admin)/admin/settings/users/_components/users-table.tsx +0 -415
  354. package/template/saas-starter/src/app/(app)/actions/organization.ts +0 -92
  355. package/template/saas-starter/src/app/proxy.ts +0 -28
  356. package/template/saas-starter/src/components/impersonation-banner.tsx +0 -44
  357. package/template/saas-starter/src/lib/organization-plan-limits.ts +0 -38
@@ -1,12 +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
+ */
1
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'
2
17
 
18
+ /**
19
+ * Phone number: 7 to 15 digits once separators are removed (E.164 maximum).
20
+ * Allowed characters are digits, `+`, `-`, `()`, `.`, and spaces.
21
+ */
3
22
  export const phoneValidation = z
4
23
  .string()
5
24
  .trim()
6
- .min(10)
7
25
  .max(20)
8
- .regex(/^[\d+\-().\s]+$/)
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.')
9
31
 
32
+ /** Email address, trimmed and lowercased. */
10
33
  export const emailValidation = z.email().trim().toLowerCase()
11
34
 
35
+ /** Required short string, 1 to 255 characters after trimming. */
12
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
+ }
@@ -1,11 +1,85 @@
1
- const seedData = async () => {}
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
+ }
2
74
 
3
75
  if (import.meta.main) {
4
- try {
5
- await seedData()
6
- } catch (err) {
7
- console.error('Error seeding data:', err)
8
- } finally {
9
- process.exit(0)
10
- }
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
+ })
11
85
  }
@@ -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
+ }
@@ -1,3 +1,10 @@
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
+ */
1
8
  import { writeFile } from 'fs/promises'
2
9
  import type Stripe from 'stripe'
3
10
  import { env } from '~/env'
@@ -190,11 +197,10 @@ const setupPortalConfiguration = async (paidPlans: PaidPlanPrices[]) => {
190
197
  }
191
198
 
192
199
  if (import.meta.main) {
193
- try {
194
- await setupStripePlans()
195
- } catch (err) {
196
- console.error('Error setting up stripe plans:', err)
197
- } finally {
198
- process.exit(0)
199
- }
200
+ setupStripePlans()
201
+ .then(() => process.exit(0))
202
+ .catch(err => {
203
+ console.error('Error setting up Stripe plans:', err)
204
+ process.exit(1)
205
+ })
200
206
  }
@@ -1,35 +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
+
1
10
  import { render, toPlainText } from '@react-email/render'
2
11
  import TestEmail from '~/emails/test'
3
12
  import { sendEmail } from '~/lib/nodemailer'
4
13
 
5
- async function sendTestMail() {
6
- const html = await render(
7
- TestEmail({
8
- name: 'John Doe'
9
- }),
10
- {
11
- pretty: true
12
- }
13
- )
14
-
15
- const text = toPlainText(html)
14
+ async function sendTestMail(to: string) {
15
+ const html = await render(TestEmail({ name: to }), { pretty: true })
16
16
 
17
17
  const result = await sendEmail({
18
- to: 'john@example.com',
18
+ to,
19
19
  subject: 'Test Email',
20
- text,
20
+ text: toPlainText(html),
21
21
  html
22
22
  })
23
23
 
24
- console.info('Test email sent successfully', result)
24
+ console.info('Test email sent', result)
25
25
  }
26
26
 
27
27
  if (import.meta.main) {
28
- try {
29
- await sendTestMail()
30
- } catch (err) {
31
- console.error('Error sending test mail:', err)
32
- } finally {
33
- process.exit(0)
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)
34
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
+ })
35
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
+ }