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,30 +1,132 @@
1
+ import 'server-only'
2
+
1
3
  import { createSafeActionClient } from 'next-safe-action'
2
4
  import { getAuthSession } from './auth'
3
- import { recordWideEvent } from './otel'
5
+ import { AppError, AppErrorCode, isAppError } from './errors'
6
+ import { flattenForWideEvent, recordError, recordWideEvent } from './otel'
7
+
8
+ /** Shown for every unexpected error; details stay in telemetry. */
9
+ export const UNEXPECTED_ERROR_MESSAGE =
10
+ 'The request did not finish. Try again in a few minutes.'
4
11
 
5
- export const actionClient = createSafeActionClient().use(
6
- async ({ next, ctx }) => {
7
- const authSession = await getAuthSession()
12
+ type ActionServerArgs = {
13
+ parsedInput?: unknown
14
+ clientInput?: unknown
15
+ [key: string]: unknown
16
+ }
8
17
 
9
- if (authSession)
18
+ /**
19
+ * Wrap a safe-action server function with wide-event telemetry.
20
+ *
21
+ * Records `action.name`, flattened `parsedInput` (or `clientInput`), and on
22
+ * success `outcome: 'success'` plus `duration_ms`. Failures keep error fields
23
+ * from `handleServerError`; duration is still recorded.
24
+ *
25
+ * @example
26
+ * export const myAction = authActionClient
27
+ * .inputSchema(schema)
28
+ * .action(
29
+ * withWideEvent('myAction', async ({ parsedInput, ctx }) => {
30
+ * // business logic only
31
+ * })
32
+ * )
33
+ */
34
+ export function withWideEvent<TArgs extends ActionServerArgs, TResult>(
35
+ actionName: string,
36
+ serverCode: (args: TArgs) => Promise<TResult>
37
+ ): (args: TArgs) => Promise<TResult> {
38
+ return async args => {
39
+ const startedAt = Date.now()
40
+ const input = args.parsedInput ?? args.clientInput
41
+
42
+ recordWideEvent({
43
+ 'action.name': actionName,
44
+ ...flattenForWideEvent(input, 'input')
45
+ })
46
+
47
+ try {
48
+ const result = await serverCode(args)
49
+ recordWideEvent({
50
+ outcome: 'success',
51
+ duration_ms: Date.now() - startedAt
52
+ })
53
+ return result
54
+ } catch (error) {
10
55
  recordWideEvent({
11
- 'user.id': authSession.user.id,
12
- 'user.admin': authSession.isAdmin,
13
- 'session.id': authSession.session.id
56
+ outcome: 'error',
57
+ duration_ms: Date.now() - startedAt
14
58
  })
59
+ throw error
60
+ }
61
+ }
62
+ }
15
63
 
16
- return next({
17
- ctx: {
18
- ...ctx,
19
- ...authSession
20
- }
64
+ /**
65
+ * Configured safe action client with authentication middleware
66
+ * This client wraps server actions with:
67
+ * - Automatic input/output validation using Zod
68
+ * - Type-safe error handling ({@link AppError} messages reach the client)
69
+ * - Server-side authentication injection
70
+ *
71
+ * All server actions using this client automatically receive the current auth session
72
+ * in the context, so protected actions can access user information.
73
+ *
74
+ * @example
75
+ * export const myAction = actionClient
76
+ * .inputSchema(z.object({ name: z.string() }))
77
+ * .action(async ({ parsedInput: { name }, ctx }) => {
78
+ * const userId = ctx.user?.id // Auth injected automatically
79
+ * return { success: true }
80
+ * })
81
+ */
82
+ export const actionClient = createSafeActionClient({
83
+ handleServerError(e) {
84
+ if (isAppError(e)) {
85
+ recordWideEvent({
86
+ 'error.name': e.name,
87
+ 'error.code': e.code,
88
+ 'error.message': e.message,
89
+ 'error.expected': true,
90
+ outcome: 'error'
91
+ })
92
+ return e.message
93
+ }
94
+
95
+ recordError(e)
96
+ recordWideEvent({
97
+ 'error.name': e.name,
98
+ 'error.expected': false,
99
+ 'error.message': e.message,
100
+ outcome: 'error'
21
101
  })
102
+ return UNEXPECTED_ERROR_MESSAGE
22
103
  }
23
- )
104
+ }).use(async ({ next, ctx }) => {
105
+ // Retrieve current authentication session
106
+ const authSession = await getAuthSession()
107
+
108
+ // Record user information in telemetry for all actions, if available
109
+ if (authSession) {
110
+ recordWideEvent({
111
+ 'user.id': authSession.user.id,
112
+ 'user.admin': authSession.isAdmin,
113
+ 'user.superAdmin': authSession.isSuperAdmin,
114
+ 'session.id': authSession.session.id
115
+ })
116
+ }
117
+
118
+ // Inject auth session into action context for all downstream actions
119
+ return next({
120
+ ctx: {
121
+ ...ctx,
122
+ ...authSession
123
+ }
124
+ })
125
+ })
24
126
 
25
127
  export const authActionClient = actionClient.use(async ({ ctx, next }) => {
26
128
  if (!ctx?.user || !ctx?.session) {
27
- throw new Error('Unauthorized')
129
+ throw new AppError(AppErrorCode.UNAUTHORIZED, 'Sign in to continue.')
28
130
  }
29
131
 
30
132
  return next({
@@ -36,9 +138,18 @@ export const authActionClient = actionClient.use(async ({ ctx, next }) => {
36
138
  })
37
139
  })
38
140
 
141
+ /**
142
+ * Action client for the admin panel.
143
+ *
144
+ * Allows both `admin` and `superadmin` roles. Use for actions that any
145
+ * admin-panel user may run (support tickets, feedback, and contact leads).
146
+ */
39
147
  export const adminActionClient = authActionClient.use(async ({ ctx, next }) => {
40
148
  if (!ctx.isAdmin) {
41
- throw new Error('Forbidden')
149
+ throw new AppError(
150
+ AppErrorCode.FORBIDDEN,
151
+ 'You do not have permission to do this.'
152
+ )
42
153
  }
43
154
 
44
155
  return next({
@@ -50,10 +161,19 @@ export const adminActionClient = authActionClient.use(async ({ ctx, next }) => {
50
161
  })
51
162
  })
52
163
 
164
+ /**
165
+ * Action client restricted to the `superadmin` role.
166
+ *
167
+ * Use for privileged operations, such as user management, role changes, and
168
+ * impersonation, that `admin` users cannot perform.
169
+ */
53
170
  export const superAdminActionClient = adminActionClient.use(
54
171
  async ({ ctx, next }) => {
55
172
  if (!ctx.isSuperAdmin) {
56
- throw new Error('Forbidden')
173
+ throw new AppError(
174
+ AppErrorCode.FORBIDDEN,
175
+ 'You do not have permission to do this.'
176
+ )
57
177
  }
58
178
 
59
179
  return next({
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Escape characters that are special in SQL LIKE / ILIKE patterns.
3
+ *
4
+ * Postgres uses `\` as the default escape character. Without escaping, a
5
+ * user-typed `%` or `_` becomes a wildcard and can force full-table scans.
6
+ */
7
+ export function escapeLikePattern(value: string): string {
8
+ return value.replace(/[\\%_]/g, char => `\\${char}`)
9
+ }
10
+
11
+ /**
12
+ * Build a case-insensitive contains pattern: `%value%` with wildcards escaped.
13
+ */
14
+ export function likeContains(value: string): string {
15
+ return `%${escapeLikePattern(value)}%`
16
+ }
@@ -0,0 +1,7 @@
1
+ import type { Metadata } from 'next'
2
+
3
+ /** Meta robots for authenticated or non-public surfaces. */
4
+ export const privatePageRobots = {
5
+ index: false,
6
+ follow: false
7
+ } satisfies NonNullable<Metadata['robots']>
@@ -1,4 +1,20 @@
1
+ /**
2
+ * @fileoverview Brand identity and site-wide copy.
3
+ *
4
+ * Layouts, emails, legal pages, and metadata read from here. Never hardcode
5
+ * the product name anywhere else.
6
+ *
7
+ * @module lib/siteConfig
8
+ */
1
9
  export const siteConfig = {
2
10
  name: 'Project Name',
3
- description: 'Project Description'
11
+ description: 'Project Description',
12
+ logo: '/logo.svg',
13
+ logoDark: '/logo-dark.svg',
14
+ contact: {
15
+ email: 'hello@example.com'
16
+ },
17
+ legal: {
18
+ lastUpdated: 'September 25, 2026'
19
+ }
4
20
  }
@@ -1,3 +1,5 @@
1
+ import 'server-only'
2
+
1
3
  import {
2
4
  DeleteObjectCommand,
3
5
  PutObjectCommand,
@@ -11,27 +13,29 @@ import { fileUploadsTable } from '~/db/schema'
11
13
  import { env } from '~/env'
12
14
  import {
13
15
  PRESIGNED_URL_TTL_SECONDS,
14
- TEMP_CLEANUP_THRESHOLD_MS
16
+ R2_UPLOAD_EXTENSION_BY_MIME_TYPE,
17
+ TEMP_CLEANUP_THRESHOLD_MS,
18
+ type R2UploadMimeType
15
19
  } from './constants'
20
+ import { AppError, AppErrorCode } from './errors'
21
+ import {
22
+ canPersistImageValue,
23
+ isDataImageUrl,
24
+ isR2ObjectKey
25
+ } from './image-url'
16
26
 
17
27
  /*
18
- R2 Policy need to be set
28
+ The R2 bucket needs this CORS policy for browser uploads:
19
29
  [
20
30
  {
21
- "AllowedOrigins": [
22
- "http://localhost:3000"
23
- ],
24
- "AllowedMethods": [
25
- "PUT"
26
- ],
27
- "AllowedHeaders": [
28
- "content-type",
29
- "content-length"
30
- ]
31
+ "AllowedOrigins": ["http://localhost:3000"],
32
+ "AllowedMethods": ["PUT"],
33
+ "AllowedHeaders": ["content-type", "content-length"]
31
34
  }
32
35
  ]
33
36
  */
34
37
 
38
+ /** True when all five R2 env vars are set. When false, uploads fall back to base64 data URLs. */
35
39
  export function isR2Configured(): boolean {
36
40
  return !!(
37
41
  env.R2_ACCOUNT_ID &&
@@ -44,12 +48,17 @@ export function isR2Configured(): boolean {
44
48
 
45
49
  let _r2Client: S3Client | null = null
46
50
 
51
+ /**
52
+ * `requestChecksumCalculation: 'WHEN_REQUIRED'` avoids signing
53
+ * `x-amz-checksum-crc32=AAAAAA==` (empty body) into presigned PUTs, which
54
+ * breaks non-empty uploads on backends that validate the header.
55
+ */
47
56
  function getR2Client(): S3Client {
48
57
  if (_r2Client) return _r2Client
49
58
 
50
- const configured = isR2Configured()
51
- if (!configured) {
52
- throw new Error('R2 storage is not configured')
59
+ if (!isR2Configured()) {
60
+ // A configuration bug, not a customer error: masked and recorded.
61
+ throw new Error('R2 storage is not configured.')
53
62
  }
54
63
 
55
64
  _r2Client = new S3Client({
@@ -58,52 +67,94 @@ function getR2Client(): S3Client {
58
67
  credentials: {
59
68
  accessKeyId: env.R2_ACCESS_KEY_ID!,
60
69
  secretAccessKey: env.R2_SECRET_ACCESS_KEY!
61
- }
70
+ },
71
+ requestChecksumCalculation: 'WHEN_REQUIRED',
72
+ responseChecksumValidation: 'WHEN_REQUIRED'
62
73
  })
63
74
 
64
75
  return _r2Client
65
76
  }
66
77
 
78
+ /**
79
+ * Throws unless `key` sits under `uploads/organizations/<organizationId>/` and
80
+ * has a `file_uploads` row recorded for that same organization.
81
+ *
82
+ * Call this on any R2 key that comes from user input before it is persisted,
83
+ * confirmed, or deleted, so a request can never reference another tenant's
84
+ * object.
85
+ */
86
+ export async function assertOwnedR2Key(
87
+ key: string,
88
+ organizationId: string
89
+ ): Promise<void> {
90
+ if (!key.startsWith(`uploads/organizations/${organizationId}/`)) {
91
+ throw new AppError(
92
+ AppErrorCode.FORBIDDEN,
93
+ 'You do not have permission to use this file.'
94
+ )
95
+ }
96
+
97
+ const exists = await db.query.fileUploadsTable.findFirst({
98
+ where: and(
99
+ eq(fileUploadsTable.key, key),
100
+ eq(fileUploadsTable.organizationId, organizationId)
101
+ ),
102
+ columns: { id: true }
103
+ })
104
+
105
+ if (!exists) {
106
+ throw new AppError(
107
+ AppErrorCode.FORBIDDEN,
108
+ 'You do not have permission to use this file.'
109
+ )
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Deletes a file from R2 and removes its tracking row.
115
+ *
116
+ * Trusted-caller primitive with no ownership check. Validate a key that came
117
+ * from user input with {@link assertOwnedR2Key} first.
118
+ */
67
119
  export async function deleteFile(key: string): Promise<void> {
68
- const client = getR2Client()
69
- await client.send(
70
- new DeleteObjectCommand({
71
- Bucket: env.R2_BUCKET_NAME,
72
- Key: key
73
- })
120
+ await getR2Client().send(
121
+ new DeleteObjectCommand({ Bucket: env.R2_BUCKET_NAME, Key: key })
74
122
  )
75
123
 
76
124
  await db.delete(fileUploadsTable).where(eq(fileUploadsTable.key, key))
77
125
  }
78
126
 
127
+ /**
128
+ * Presigns a PUT for a direct browser-to-R2 upload and records it as
129
+ * temporary. Content-Type is signed, so the browser cannot upload `text/html`
130
+ * under an image key.
131
+ *
132
+ * The browser PUTs the file to `uploadUrl`, then the action that saves the
133
+ * resource calls {@link confirmUpload} with the returned `key`.
134
+ */
79
135
  export async function generateUploadUrl(params: {
80
136
  filename: string
81
- mimeType: string
137
+ mimeType: R2UploadMimeType
82
138
  size: number
83
139
  uploadedBy: string
84
- organizationId?: string | null
85
- }): Promise<{
86
- uploadUrl: string
87
- key: string
88
- }> {
89
- const ext = params.filename.split('.').pop()?.toLowerCase() ?? 'bin'
90
- const folder = params.organizationId
91
- ? `uploads/organizations/${params.organizationId}`
92
- : `uploads/misc`
93
-
140
+ organizationId: string
141
+ }): Promise<{ uploadUrl: string; key: string }> {
142
+ const ext = R2_UPLOAD_EXTENSION_BY_MIME_TYPE[params.mimeType]
94
143
  const fileId = createId()
95
- const key = `${folder}/${fileId}.${ext}`
144
+ const key = `uploads/organizations/${params.organizationId}/${fileId}.${ext}`
96
145
 
97
- const client = getR2Client()
98
146
  const uploadUrl = await getSignedUrl(
99
- client,
147
+ getR2Client(),
100
148
  new PutObjectCommand({
101
149
  Bucket: env.R2_BUCKET_NAME!,
102
150
  Key: key,
103
151
  ContentType: params.mimeType,
104
152
  ContentLength: params.size
105
153
  }),
106
- { expiresIn: PRESIGNED_URL_TTL_SECONDS }
154
+ {
155
+ expiresIn: PRESIGNED_URL_TTL_SECONDS,
156
+ signableHeaders: new Set(['content-type'])
157
+ }
107
158
  )
108
159
 
109
160
  await db.insert(fileUploadsTable).values({
@@ -120,35 +171,114 @@ export async function generateUploadUrl(params: {
120
171
  return { uploadUrl, key }
121
172
  }
122
173
 
174
+ /**
175
+ * Marks an upload permanent and deletes the file it replaces.
176
+ *
177
+ * Call it in the action that saves the resource. `newKey` must belong to
178
+ * `organizationId`, and this throws otherwise. Deleting `oldKey` is
179
+ * best-effort and never blocks the save.
180
+ */
123
181
  export async function confirmUpload(
124
182
  newKey: string,
183
+ organizationId: string,
125
184
  oldKey?: string | null
126
185
  ): Promise<void> {
186
+ await assertOwnedR2Key(newKey, organizationId)
187
+
127
188
  await db
128
189
  .update(fileUploadsTable)
129
190
  .set({ isTemp: false })
130
191
  .where(eq(fileUploadsTable.key, newKey))
131
192
 
132
- if (oldKey && isR2Configured()) {
133
- await deleteFile(oldKey).catch(() => {
134
- // Non-fatal: old file cleanup failure should not block the save
135
- })
193
+ if (oldKey && oldKey !== newKey)
194
+ await deleteOwnedFileQuietly(oldKey, organizationId)
195
+ }
196
+
197
+ /**
198
+ * Deletes a replaced or cleared file when `organizationId` owns it. Failures
199
+ * are swallowed: a stale object costs storage, not correctness.
200
+ */
201
+ export async function deleteOwnedFileQuietly(
202
+ key: string,
203
+ organizationId: string
204
+ ): Promise<void> {
205
+ if (!isR2Key(key) || !isR2Configured()) return
206
+
207
+ const owned = await assertOwnedR2Key(key, organizationId)
208
+ .then(() => true)
209
+ .catch(() => false)
210
+
211
+ if (owned) await deleteFile(key).catch(() => {})
212
+ }
213
+
214
+ /**
215
+ * Deletes every upload an organization owns, from R2 and the database.
216
+ * `file_uploads.organization_id` restricts deletes, so the organization row
217
+ * can only go once this has run.
218
+ */
219
+ export async function deleteOrganizationUploads(
220
+ organizationId: string
221
+ ): Promise<void> {
222
+ const uploads = await db
223
+ .select({ key: fileUploadsTable.key })
224
+ .from(fileUploadsTable)
225
+ .where(eq(fileUploadsTable.organizationId, organizationId))
226
+
227
+ if (isR2Configured()) {
228
+ for (const upload of uploads) {
229
+ await deleteFile(upload.key)
230
+ }
136
231
  }
232
+
233
+ await db
234
+ .delete(fileUploadsTable)
235
+ .where(eq(fileUploadsTable.organizationId, organizationId))
137
236
  }
138
237
 
238
+ /** True when `value` is an R2 object key (`uploads/organizations/<id>/<file>.<ext>`). */
139
239
  export function isR2Key(value: string | null | undefined): value is string {
140
- return !!value && !value.startsWith('data:') && !value.startsWith('https://')
240
+ return !!value && isR2ObjectKey(value)
241
+ }
242
+
243
+ /**
244
+ * Rejects image values that must not be written to a resource. This blocks
245
+ * `data:` URLs when R2 is configured. Ownership stays in {@link assertOwnedR2Key}.
246
+ */
247
+ export function assertPersistableImageValue(
248
+ value: string | null | undefined
249
+ ): void {
250
+ if (!value) return
251
+ if (canPersistImageValue(value, isR2Configured())) return
252
+ throw new AppError(
253
+ AppErrorCode.BAD_REQUEST,
254
+ 'Upload an image file. Embedded images are not stored.'
255
+ )
141
256
  }
142
257
 
258
+ /**
259
+ * Resolves a stored image value to a renderable URL.
260
+ *
261
+ * - R2 object key: prefixed with `R2_PUBLIC_URL`
262
+ * - `data:image/*;base64,...` (dev without R2): returned as-is
263
+ * - anything else, including `https://` and `javascript:`: `null`
264
+ */
143
265
  export function resolveImageUrl(
144
266
  value: string | null | undefined
145
267
  ): string | null {
146
268
  if (!value) return null
147
- if (!isR2Key(value)) return value
148
- if (!isR2Configured()) return null
149
- return `${env.R2_PUBLIC_URL}/${value}`
269
+ if (isR2ObjectKey(value)) {
270
+ if (!isR2Configured()) return null
271
+ return `${env.R2_PUBLIC_URL}/${value}`
272
+ }
273
+ if (isDataImageUrl(value)) return value
274
+ return null
150
275
  }
151
276
 
277
+ /**
278
+ * Deletes temporary uploads older than {@link TEMP_CLEANUP_THRESHOLD_MS}.
279
+ * Per-file errors are counted, not thrown, so one failure cannot abort the
280
+ * batch. `bun run storage:cleanup` runs it; schedule that command.
281
+ */
152
282
  export async function cleanupTempUploads(): Promise<{
153
283
  deleted: number
154
284
  errors: number
@@ -156,7 +286,7 @@ export async function cleanupTempUploads(): Promise<{
156
286
  const threshold = new Date(Date.now() - TEMP_CLEANUP_THRESHOLD_MS)
157
287
 
158
288
  const stale = await db
159
- .select({ id: fileUploadsTable.id, key: fileUploadsTable.key })
289
+ .select({ key: fileUploadsTable.key })
160
290
  .from(fileUploadsTable)
161
291
  .where(
162
292
  and(
@@ -1,6 +1,9 @@
1
+ import 'server-only'
2
+
1
3
  import Stripe from 'stripe'
2
4
  import { env } from '~/env'
3
5
 
6
+ /** Pinned to the API version this `stripe` SDK release was built for. Bump both together. */
4
7
  export const stripeClient = new Stripe(env.STRIPE_SECRET_KEY, {
5
- apiVersion: '2026-05-27.dahlia'
8
+ apiVersion: '2026-08-26.dahlia'
6
9
  })
@@ -2,14 +2,100 @@
2
2
 
3
3
  import { toast } from 'sonner'
4
4
 
5
+ const DEFAULT_ERROR = 'The request did not finish. Try again in a few minutes.'
6
+
7
+ type ActionResultLike = {
8
+ serverError?: string
9
+ validationErrors?: unknown
10
+ }
11
+
12
+ /**
13
+ * Displays a success toast notification to the user.
14
+ */
5
15
  export const toastSuccessMessage = (message: string) =>
6
16
  toast.success(message, {
7
17
  richColors: true,
8
18
  position: 'top-center'
9
19
  })
10
20
 
21
+ /**
22
+ * Displays an informational toast (neutral, non-error feedback).
23
+ */
24
+ export const toastInfoMessage = (message: string) =>
25
+ toast.info(message, {
26
+ richColors: true,
27
+ position: 'top-center'
28
+ })
29
+
30
+ /**
31
+ * Displays an error toast. Uses a default message when none is provided.
32
+ */
11
33
  export const toastErrorMessage = (message?: string) =>
12
- toast.error(message || 'Something went wrong. Please try again later.', {
34
+ toast.error(message || DEFAULT_ERROR, {
13
35
  richColors: true,
14
36
  position: 'top-center'
15
37
  })
38
+
39
+ /**
40
+ * Toast an action failure from `await someAction(...)`.
41
+ * Returns true when a toast was shown so callers can bail.
42
+ *
43
+ * @example
44
+ * const result = await upsertTableAction(values)
45
+ * if (toastIfActionFailed(result)) return
46
+ */
47
+ export function toastIfActionFailed(
48
+ result: ActionResultLike | undefined | null
49
+ ): boolean {
50
+ if (!result) return false
51
+ if (result.serverError) {
52
+ toastErrorMessage(result.serverError)
53
+ return true
54
+ }
55
+ if (result.validationErrors) {
56
+ toastErrorMessage('Check the highlighted fields and try again.')
57
+ return true
58
+ }
59
+ return false
60
+ }
61
+
62
+ /**
63
+ * Toast a failure from hooks (`useSafeActionMutation`, `useAction`) or a thrown Error.
64
+ *
65
+ * Accepts:
66
+ * - `SafeActionError` / `Error` (`message`)
67
+ * - `useAction` error bag (`{ serverError, validationErrors }`)
68
+ * - plain string
69
+ *
70
+ * @example
71
+ * onError: error => toastActionError(error, 'Failed to save')
72
+ * onError: ({ error }) => toastActionError(error, 'Failed to save')
73
+ */
74
+ export function toastActionError(
75
+ error: unknown,
76
+ fallback = DEFAULT_ERROR
77
+ ): void {
78
+ if (typeof error === 'string' && error.trim()) {
79
+ toastErrorMessage(error)
80
+ return
81
+ }
82
+
83
+ if (error && typeof error === 'object') {
84
+ const bag = error as ActionResultLike & { message?: string }
85
+
86
+ if (typeof bag.serverError === 'string' && bag.serverError) {
87
+ toastErrorMessage(bag.serverError)
88
+ return
89
+ }
90
+ if (bag.validationErrors) {
91
+ toastErrorMessage('Check the highlighted fields and try again.')
92
+ return
93
+ }
94
+ if (typeof bag.message === 'string' && bag.message.trim()) {
95
+ toastErrorMessage(bag.message)
96
+ return
97
+ }
98
+ }
99
+
100
+ toastErrorMessage(fallback)
101
+ }