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,14 +1,27 @@
1
+ import 'server-only'
2
+
1
3
  import { eq } from 'drizzle-orm'
2
4
  import { db } from '~/db'
3
- import { organizationSubscriptionsTable, organizationsTable } from '~/db/schema'
4
- import { PLANS, type Plan } from './plans'
5
+ import {
6
+ organizationSubscriptionStatusEnum,
7
+ organizationSubscriptionsTable,
8
+ organizationsTable
9
+ } from '~/db/schema'
10
+ import { AppError, AppErrorCode } from './errors'
11
+ import { PLANS, hasLiveSubscription, type PlanId } from './plans'
5
12
  import { stripeClient } from './stripe'
6
13
 
7
14
  type SubscriptionRow = typeof organizationSubscriptionsTable.$inferSelect
15
+ type SubscriptionStatus = SubscriptionRow['status']
16
+
17
+ const KNOWN_STATUSES = new Set<string>(
18
+ organizationSubscriptionStatusEnum.enumValues
19
+ )
8
20
 
21
+ /** Maps a Stripe price back to the plan and billing interval it belongs to. */
9
22
  export const getPlanAndIntervalByPriceId = (
10
23
  priceId: string
11
- ): { planId: Plan['id']; interval: 'month' | 'year' } | null => {
24
+ ): { planId: PlanId; interval: 'month' | 'year' } | null => {
12
25
  for (const plan of PLANS) {
13
26
  if (!plan.stripePrice) continue
14
27
  if (plan.stripePrice.monthly.id === priceId)
@@ -19,56 +32,71 @@ export const getPlanAndIntervalByPriceId = (
19
32
  return null
20
33
  }
21
34
 
35
+ /** One trial per organization, and only from the free plan. */
22
36
  export const canStartTrial = (
23
37
  sub: Pick<SubscriptionRow, 'status' | 'hasUsedTrial'>
24
38
  ): boolean => sub.status === 'free' && !sub.hasUsedTrial
25
39
 
40
+ /**
41
+ * Returns the organization's Stripe customer, creating one on first checkout.
42
+ * A billing email is required first so receipts have somewhere to go.
43
+ */
26
44
  export const ensureStripeCustomer = async (
27
- organizationId: string
45
+ sub: SubscriptionRow
28
46
  ): Promise<string> => {
29
- const sub = await db.query.organizationSubscriptionsTable.findFirst({
30
- where: eq(organizationSubscriptionsTable.organizationId, organizationId)
31
- })
32
-
33
- if (!sub) {
34
- throw new Error('Organization does not have a subscription')
35
- }
36
-
37
47
  if (sub.stripeCustomerId) return sub.stripeCustomerId
38
48
 
39
49
  if (!sub.billingEmail) {
40
- throw new Error('Set a billing email before subscribing')
50
+ throw new AppError(
51
+ AppErrorCode.BAD_REQUEST,
52
+ 'Add a billing email before you choose a paid plan.'
53
+ )
41
54
  }
42
55
 
43
56
  const org = await db.query.organizationsTable.findFirst({
44
- where: eq(organizationsTable.id, organizationId),
57
+ where: eq(organizationsTable.id, sub.organizationId),
45
58
  columns: { name: true }
46
59
  })
47
60
 
48
- const customer = await stripeClient.customers.create({
49
- email: sub.billingEmail,
50
- name: org?.name,
51
- metadata: { organizationId }
52
- })
61
+ // The idempotency key makes a double-clicked checkout reuse one customer
62
+ // instead of orphaning the first one's webhooks.
63
+ const customer = await stripeClient.customers.create(
64
+ {
65
+ email: sub.billingEmail,
66
+ name: org?.name,
67
+ metadata: { organizationId: sub.organizationId }
68
+ },
69
+ { idempotencyKey: `organization-${sub.organizationId}-customer` }
70
+ )
53
71
 
54
72
  await db
55
73
  .update(organizationSubscriptionsTable)
56
74
  .set({ stripeCustomerId: customer.id })
57
- .where(eq(organizationSubscriptionsTable.organizationId, organizationId))
75
+ .where(eq(organizationSubscriptionsTable.id, sub.id))
58
76
 
59
77
  return customer.id
60
78
  }
61
79
 
80
+ /**
81
+ * Copies the customer's latest Stripe subscription onto the organization.
82
+ *
83
+ * Webhooks call this for every relevant event, so it reads the current state
84
+ * from Stripe instead of trusting the event payload, which may arrive out of
85
+ * order.
86
+ */
62
87
  export const syncSubscriptionFromStripe = async (
63
88
  customerId: string
64
89
  ): Promise<void> => {
65
90
  const subs = await stripeClient.subscriptions.list({
66
91
  customer: customerId,
67
92
  status: 'all',
68
- limit: 1
93
+ limit: 10
69
94
  })
70
95
 
71
- const sub = subs.data[0]
96
+ // Newest first. Prefer a subscription that still bills over a newer one
97
+ // that ended, so the organization never drops to free while paying.
98
+ const sub =
99
+ subs.data.find(item => hasLiveSubscription(item.status)) ?? subs.data[0]
72
100
 
73
101
  if (
74
102
  !sub ||
@@ -90,12 +118,19 @@ export const syncSubscriptionFromStripe = async (
90
118
  return
91
119
  }
92
120
 
121
+ if (!KNOWN_STATUSES.has(sub.status)) {
122
+ throw new AppError(
123
+ AppErrorCode.INTERNAL,
124
+ `Stripe subscription ${sub.id} has unknown status ${sub.status}. Add it to the status enum to sync it.`
125
+ )
126
+ }
127
+
93
128
  const item = sub.items.data[0]
94
129
  const resolved = item ? getPlanAndIntervalByPriceId(item.price.id) : null
95
130
 
96
131
  if (!resolved) {
97
132
  console.error(
98
- `Stripe price ${item?.price.id} on subscription ${sub.id} does not map to a known plan`
133
+ `Stripe price ${item?.price.id} on subscription ${sub.id} does not map to a plan; the organization stays on the free plan.`
99
134
  )
100
135
  }
101
136
 
@@ -105,7 +140,7 @@ export const syncSubscriptionFromStripe = async (
105
140
  .update(organizationSubscriptionsTable)
106
141
  .set({
107
142
  plan: resolved?.planId ?? 'free',
108
- status: sub.status,
143
+ status: sub.status as SubscriptionStatus,
109
144
  interval: resolved?.interval ?? null,
110
145
  stripeSubscriptionId: sub.id,
111
146
  trialEndsAt: sub.trial_end ? new Date(sub.trial_end * 1000) : null,
@@ -0,0 +1,28 @@
1
+ /** Origin used only to resolve a candidate callback and check where it lands. */
2
+ const RESOLUTION_ORIGIN = 'https://callback.invalid'
3
+
4
+ /**
5
+ * Narrow a caller-supplied `?callbackUrl=` down to a same-origin path.
6
+ *
7
+ * Returns the normalised path, or null when the value could leave the site.
8
+ * A leading `/` is not enough on its own: `//evil.com`, `/\evil.com`, and
9
+ * `/<tab>/evil.com` all resolve cross-origin in a browser, the last because
10
+ * URL parsing strips tabs and newlines first. The shape checks below reject
11
+ * the known forms, and resolving against a throwaway origin catches the rest.
12
+ */
13
+ export function sanitizeCallbackUrl(
14
+ value: string | string[] | undefined
15
+ ): string | null {
16
+ if (typeof value !== 'string') return null
17
+ if (!/^\/(?![/\\])/.test(value)) return null
18
+ if (/[\t\n\r]/.test(value)) return null
19
+
20
+ try {
21
+ const resolved = new URL(value, RESOLUTION_ORIGIN)
22
+ if (resolved.origin !== RESOLUTION_ORIGIN) return null
23
+
24
+ return `${resolved.pathname}${resolved.search}${resolved.hash}`
25
+ } catch {
26
+ return null
27
+ }
28
+ }
@@ -1,22 +1,91 @@
1
+ /**
2
+ * @fileoverview Application-wide constants for configuration and validation.
3
+ *
4
+ * Read limits, labels, and repeated values from here in the UI, validation,
5
+ * tests, and docs, so one change updates every place.
6
+ *
7
+ * @module lib/constants
8
+ */
9
+
1
10
  // ============================================
2
- // BILLING CONFIGURATION
11
+ // BILLING
3
12
  // ============================================
4
13
 
5
- /* Trial days */
14
+ /** Length of the free trial on paid plans that offer one */
6
15
  export const TRIAL_DAYS = 7
7
16
 
8
17
  // ============================================
9
- // R2 STORAGE CONFIGURATION
18
+ // ORGANIZATIONS
19
+ // ============================================
20
+
21
+ /** Organizations one user can create */
22
+ export const MAX_ORGANIZATIONS_PER_USER = 10
23
+
24
+ /** Days an organization invitation stays valid */
25
+ export const INVITATION_EXPIRES_IN_DAYS = 7
26
+
27
+ // ============================================
28
+ // R2 STORAGE
10
29
  // ============================================
11
30
 
12
- /* Presigned URL TTL in seconds */
13
- export const PRESIGNED_URL_TTL_SECONDS = 900 // 15 min to complete the upload
31
+ /** Seconds the client has to complete a presigned upload */
32
+ export const PRESIGNED_URL_TTL_SECONDS = 900
14
33
 
15
- /* Temp cleanup threshold in milliseconds */
16
- export const TEMP_CLEANUP_THRESHOLD_MS = 2 * 60 * 60 * 1000 // 2 hours
34
+ /** Age after which an unconfirmed upload is deleted */
35
+ export const TEMP_CLEANUP_THRESHOLD_MS = 2 * 60 * 60 * 1000
17
36
 
18
- /* Maximum file size in MB */
37
+ /** Maximum upload size in MB */
19
38
  export const MAX_FILE_SIZE_MB = 5
20
39
 
21
- /* Maximum file size in bytes */
40
+ /** Maximum upload size in bytes */
22
41
  export const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024
42
+
43
+ /** MIME types the upload flow accepts, mapped to the extension stored on the object key */
44
+ export const R2_UPLOAD_EXTENSION_BY_MIME_TYPE = {
45
+ 'image/jpeg': 'jpg',
46
+ 'image/png': 'png',
47
+ 'image/webp': 'webp',
48
+ 'image/gif': 'gif'
49
+ } as const
50
+
51
+ export type R2UploadMimeType = keyof typeof R2_UPLOAD_EXTENSION_BY_MIME_TYPE
52
+
53
+ // ============================================
54
+ // REQUEST ARRAY BOUNDS
55
+ // ============================================
56
+
57
+ /*
58
+ * Ceilings for arrays that reach SQL as an `IN (…)` list. Postgres caps a
59
+ * statement at 65535 bind parameters, and an unbounded array also builds in
60
+ * server memory first.
61
+ */
62
+
63
+ /** Values accepted in one list-filter array, such as status facets */
64
+ export const MAX_FILTER_VALUES = 100
65
+
66
+ // ============================================
67
+ // TEXT AND INTEGER INPUT BOUNDS
68
+ // ============================================
69
+
70
+ /**
71
+ * Image value ceiling.
72
+ * Covers R2 object keys and base64 data URLs for the max upload size.
73
+ */
74
+ export const MAX_IMAGE_URL_LENGTH =
75
+ Math.ceil(MAX_FILE_SIZE_BYTES * (4 / 3)) + 64
76
+
77
+ /** URL slug for organizations */
78
+ export const MAX_SLUG_LENGTH = 64
79
+
80
+ /** Feedback comments */
81
+ export const MAX_COMMENT_LENGTH = 3_000
82
+
83
+ /** Support ticket bodies and contact form messages */
84
+ export const MAX_MESSAGE_LENGTH = 5_000
85
+
86
+ /** Shortest contact form message worth answering */
87
+ export const MIN_CONTACT_MESSAGE_LENGTH = 10
88
+
89
+ /** Star ratings on feedback */
90
+ export const MIN_RATING = 1
91
+ export const MAX_RATING = 5
@@ -1,2 +1,12 @@
1
+ /**
2
+ * Creates a promise that resolves after a specified delay
3
+ * Useful for adding delays in async functions or testing
4
+ *
5
+ * @param ms - Milliseconds to delay
6
+ * @returns Promise that resolves after the delay
7
+ *
8
+ * @example
9
+ * await delay(1000) // Wait 1 second
10
+ */
1
11
  export const delay = (ms: number) =>
2
12
  new Promise(resolve => setTimeout(resolve, ms))
@@ -1,3 +1,5 @@
1
+ import 'server-only'
2
+
1
3
  import { render, toPlainText } from '@react-email/render'
2
4
  import InvitationEmail, { InvitationEmailProps } from '~/emails/invitation'
3
5
  import MagicLinkEmail, { MagicLinkEmailProps } from '~/emails/magic-link'
@@ -25,6 +27,9 @@ const componentMap: Record<
25
27
  invitation: props => InvitationEmail(props as InvitationEmailProps)
26
28
  }
27
29
 
30
+ /**
31
+ * Generic email template renderer with type-safe overloads
32
+ */
28
33
  async function renderEmailTemplate<T extends EmailTemplateType>(
29
34
  templateType: T,
30
35
  props: EmailTemplateMap[T]
@@ -43,6 +48,14 @@ async function renderEmailTemplate(
43
48
  return { html, text }
44
49
  }
45
50
 
51
+ /**
52
+ * Sends welcome email to newly registered users
53
+ *
54
+ * @param to - Recipient email address
55
+ * @param props - Welcome email template props
56
+ * @returns Promise resolving to SentMessageInfo from nodemailer
57
+ * @throws Error if sending the email fails
58
+ */
46
59
  export async function sendWelcomeEmail(to: string, props: WelcomeEmailProps) {
47
60
  'use workflow'
48
61
 
@@ -56,6 +69,14 @@ export async function sendWelcomeEmail(to: string, props: WelcomeEmailProps) {
56
69
  })
57
70
  }
58
71
 
72
+ /**
73
+ * Sends magic link email for passwordless authentication
74
+ *
75
+ * @param to - Recipient email address
76
+ * @param props - Magic link email template props
77
+ * @returns Promise resolving to SentMessageInfo from nodemailer
78
+ * @throws Error if sending the email fails
79
+ */
59
80
  export async function sendMagicLinkEmail(
60
81
  to: string,
61
82
  props: MagicLinkEmailProps
@@ -72,6 +93,14 @@ export async function sendMagicLinkEmail(
72
93
  })
73
94
  }
74
95
 
96
+ /**
97
+ * Sends an organization invitation email
98
+ *
99
+ * @param to - Recipient email address
100
+ * @param props - Invitation email template props
101
+ * @returns Promise resolving to SentMessageInfo from nodemailer
102
+ * @throws Error if sending the email fails
103
+ */
75
104
  export async function sendInvitationEmail(
76
105
  to: string,
77
106
  props: InvitationEmailProps
@@ -82,7 +111,7 @@ export async function sendInvitationEmail(
82
111
 
83
112
  return await sendEmail({
84
113
  to,
85
- subject: `You've been invited to join ${props.organizationName}`,
114
+ subject: `${props.inviterName} invited you to join ${props.organizationName}`,
86
115
  html,
87
116
  text
88
117
  })
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Expected, user-safe action failures.
3
+ *
4
+ * Throw these for domain and authorization failures. The safe-action
5
+ * `handleServerError` returns {@link AppError.message} to the client and
6
+ * records the code on the active span. Unexpected errors stay masked.
7
+ *
8
+ * Do not put stack traces, SQL, or internal IDs in `message`.
9
+ */
10
+ export const AppErrorCode = {
11
+ UNAUTHORIZED: 'UNAUTHORIZED',
12
+ FORBIDDEN: 'FORBIDDEN',
13
+ NOT_FOUND: 'NOT_FOUND',
14
+ CONFLICT: 'CONFLICT',
15
+ QUOTA_EXCEEDED: 'QUOTA_EXCEEDED',
16
+ FEATURE_NOT_INCLUDED: 'FEATURE_NOT_INCLUDED',
17
+ BAD_REQUEST: 'BAD_REQUEST',
18
+ INTERNAL: 'INTERNAL'
19
+ } as const
20
+
21
+ export type AppErrorCode = (typeof AppErrorCode)[keyof typeof AppErrorCode]
22
+
23
+ export class AppError extends Error {
24
+ readonly code: AppErrorCode
25
+ readonly meta?: Record<string, unknown>
26
+
27
+ constructor(
28
+ code: AppErrorCode,
29
+ message: string,
30
+ meta?: Record<string, unknown>
31
+ ) {
32
+ super(message)
33
+ this.name = 'AppError'
34
+ this.code = code
35
+ this.meta = meta
36
+ }
37
+ }
38
+
39
+ export function isAppError(error: unknown): error is AppError {
40
+ return error instanceof AppError
41
+ }
42
+
43
+ /**
44
+ * The Postgres SQLSTATE of a failed query. Drizzle wraps driver errors and
45
+ * keeps the original on `cause`, so read both.
46
+ */
47
+ function postgresCode(error: unknown, depth = 0): unknown {
48
+ // Drizzle adds one layer; the cap stops an error whose cause points back at
49
+ // itself from looping forever.
50
+ if (depth > 5 || typeof error !== 'object' || error === null) return undefined
51
+ if ('code' in error && typeof error.code === 'string') return error.code
52
+ return 'cause' in error ? postgresCode(error.cause, depth + 1) : undefined
53
+ }
54
+
55
+ export function isUniqueViolation(error: unknown): boolean {
56
+ return postgresCode(error) === '23505'
57
+ }
58
+
59
+ /**
60
+ * True when a delete or update is refused because another row still points at
61
+ * the row: a foreign key violation, or a `restrict` violation for keys declared
62
+ * `onDelete: 'restrict'`.
63
+ */
64
+ export function isForeignKeyViolation(error: unknown): boolean {
65
+ const code = postgresCode(error)
66
+ return code === '23503' || code === '23001'
67
+ }
68
+
69
+ export function rethrowUniqueViolation(error: unknown, message: string): never {
70
+ if (isUniqueViolation(error)) {
71
+ throw new AppError(AppErrorCode.CONFLICT, message)
72
+ }
73
+ throw error
74
+ }
@@ -1,27 +1,25 @@
1
- export const formatCurrency = (
2
- currencyCode: string,
3
- num: number | string,
4
- precision = 2
5
- ) => {
6
- const numCopy = Number(num)
1
+ const formatters = new Map<string, Intl.NumberFormat>()
7
2
 
8
- switch (currencyCode) {
9
- case 'INR':
10
- return new Intl.NumberFormat('en-IN', {
11
- style: 'currency',
12
- currency: 'INR',
13
- signDisplay: 'negative'
14
- }).format(numCopy)
15
- case 'USD':
16
- return new Intl.NumberFormat('en-US', {
17
- style: 'currency',
18
- currency: 'USD',
19
- signDisplay: 'negative'
20
- }).format(numCopy)
21
- default:
22
- console.warn(
23
- `Unsupported currency: ${currencyCode}. Defaulting to fixed format.`
24
- )
25
- return numCopy.toFixed(precision)
3
+ /**
4
+ * Formats an amount in its currency, such as `$20.00` or `€20.00`.
5
+ * `Intl` picks the symbol and the minor digits from the ISO currency code.
6
+ *
7
+ * @example
8
+ * formatCurrency('USD', 20) // '$20.00'
9
+ */
10
+ export function formatCurrency(
11
+ currencyCode: string,
12
+ value: number | string
13
+ ): string {
14
+ let formatter = formatters.get(currencyCode)
15
+ if (!formatter) {
16
+ formatter = new Intl.NumberFormat('en-US', {
17
+ style: 'currency',
18
+ currency: currencyCode,
19
+ currencyDisplay: 'narrowSymbol',
20
+ signDisplay: 'negative'
21
+ })
22
+ formatters.set(currencyCode, formatter)
26
23
  }
24
+ return formatter.format(Number(value))
27
25
  }
@@ -1,12 +1,75 @@
1
+ export type DateFormatOptions = {
2
+ /** Abbreviated month ("Jan") instead of the full name. */
3
+ short?: boolean
4
+ /** Append the time of day. */
5
+ time?: boolean
6
+ /** Drop the year. */
7
+ noYear?: boolean
8
+ /** Render in this IANA zone instead of the viewer's. */
9
+ timeZone?: string
10
+ }
11
+
12
+ function asValidDate(
13
+ date: Date | string | number | null | undefined
14
+ ): Date | null {
15
+ if (date == null) return null
16
+ const instant = new Date(date)
17
+ return Number.isFinite(instant.getTime()) ? instant : null
18
+ }
19
+
20
+ /**
21
+ * Date and time are formatted separately and joined with a fixed " at ",
22
+ * because the connector ICU inserts between them changed across versions
23
+ * (", " in ICU 72 and later). Bun, Node, and each browser ship different ICU
24
+ * data, so one `toLocaleString` call renders differently per viewer and
25
+ * breaks hydration.
26
+ */
27
+ function joinDateAndTime(
28
+ instant: Date,
29
+ dateOptions: Intl.DateTimeFormatOptions,
30
+ timeOptions: Intl.DateTimeFormatOptions,
31
+ timeZone: string | undefined
32
+ ): string {
33
+ const zone = timeZone ? { timeZone } : {}
34
+ const datePart = instant.toLocaleDateString('en-US', {
35
+ ...dateOptions,
36
+ ...zone
37
+ })
38
+ const timePart = instant.toLocaleTimeString('en-US', {
39
+ ...timeOptions,
40
+ ...zone
41
+ })
42
+ return `${datePart} at ${timePart}`
43
+ }
44
+
45
+ /**
46
+ * Formats a date as an `en-US` string, such as "March 3, 2026".
47
+ * Returns `-` for a missing or invalid date.
48
+ */
1
49
  export const formatDate = (
2
50
  date: Date | string | number | null | undefined,
3
- options?: { short?: boolean; time?: boolean; noYear?: boolean }
51
+ options?: DateFormatOptions
4
52
  ): string => {
5
- if (date == null) return '-'
6
- return new Date(date).toLocaleDateString('en-US', {
53
+ const instant = asValidDate(date)
54
+ if (!instant) return '-'
55
+
56
+ const dateOptions: Intl.DateTimeFormatOptions = {
7
57
  ...(options?.noYear ? {} : { year: 'numeric' }),
8
58
  month: options?.short ? 'short' : 'long',
9
- day: 'numeric',
10
- ...(options?.time ? { hour: '2-digit', minute: '2-digit' } : {})
11
- })
59
+ day: 'numeric'
60
+ }
61
+
62
+ if (!options?.time) {
63
+ return instant.toLocaleDateString('en-US', {
64
+ ...dateOptions,
65
+ ...(options?.timeZone ? { timeZone: options.timeZone } : {})
66
+ })
67
+ }
68
+
69
+ return joinDateAndTime(
70
+ instant,
71
+ dateOptions,
72
+ { hour: '2-digit', minute: '2-digit' },
73
+ options.timeZone
74
+ )
12
75
  }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Image field formats stored on organizations.
3
+ *
4
+ * Client-safe: no env, DB, or server-only imports. Zod schemas and
5
+ * `resolveImageUrl` share these predicates so only R2 keys and data image
6
+ * URLs reach the renderer.
7
+ */
8
+ import {
9
+ R2_UPLOAD_EXTENSION_BY_MIME_TYPE,
10
+ type R2UploadMimeType
11
+ } from '~/lib/constants'
12
+
13
+ const IMAGE_EXTENSIONS = Object.values(R2_UPLOAD_EXTENSION_BY_MIME_TYPE)
14
+ const IMAGE_SUBTYPES = (
15
+ Object.keys(R2_UPLOAD_EXTENSION_BY_MIME_TYPE) as R2UploadMimeType[]
16
+ ).map(mime => mime.slice('image/'.length))
17
+
18
+ const R2_OBJECT_KEY_RE = new RegExp(
19
+ `^uploads/organizations/[a-z0-9_-]+/[a-zA-Z0-9._-]+\\.(${IMAGE_EXTENSIONS.join('|')})$`
20
+ )
21
+ const DATA_IMAGE_URL_RE = new RegExp(
22
+ `^data:image/(?:${IMAGE_SUBTYPES.join('|')});base64,[A-Za-z0-9+/]+={0,2}$`
23
+ )
24
+
25
+ /** True when `value` is an owned-upload key (`uploads/organizations/<id>/<file>.<ext>`). */
26
+ export function isR2ObjectKey(value: string): boolean {
27
+ return R2_OBJECT_KEY_RE.test(value)
28
+ }
29
+
30
+ /** True when `value` is a `data:image/*;base64,...` URL of an allowed MIME type. */
31
+ export function isDataImageUrl(value: string): boolean {
32
+ return DATA_IMAGE_URL_RE.test(value)
33
+ }
34
+
35
+ /** True when `value` is an R2 object key or a data image URL. */
36
+ export function isStoredImageValue(value: string): boolean {
37
+ return isR2ObjectKey(value) || isDataImageUrl(value)
38
+ }
39
+
40
+ /**
41
+ * Persist rules after format checks.
42
+ *
43
+ * R2 keys are always eligible (ownership is a separate DB check). Data URLs
44
+ * are only eligible when R2 is off; otherwise they skip the upload tracking
45
+ * and inline megabytes into every page that shows the image.
46
+ */
47
+ export function canPersistImageValue(
48
+ value: string,
49
+ r2Configured: boolean
50
+ ): boolean {
51
+ if (isR2ObjectKey(value)) return true
52
+ if (isDataImageUrl(value)) return !r2Configured
53
+ return false
54
+ }
@@ -1,16 +1,26 @@
1
+ import 'server-only'
2
+
1
3
  import nodemailer, { SentMessageInfo } from 'nodemailer'
2
4
  import { env } from '~/env'
5
+ import { AppError, AppErrorCode } from './errors'
3
6
 
7
+ /**
8
+ * Configure the email transporter using environment variables.
9
+ * Uses nodemailer for sending emails via SMTP.
10
+ */
4
11
  const transporter = nodemailer.createTransport({
5
12
  host: env.EMAIL_SERVER_HOST,
6
- port: parseInt(env.EMAIL_SERVER_PORT),
7
- secure: parseInt(env.EMAIL_SERVER_PORT) === 465, // Use secure for port 465, false for other ports
13
+ port: env.EMAIL_SERVER_PORT,
14
+ secure: env.EMAIL_SERVER_PORT === 465, // Use secure for port 465, false for other ports
8
15
  auth: {
9
16
  user: env.EMAIL_SERVER_USER,
10
17
  pass: env.EMAIL_SERVER_PASSWORD
11
18
  }
12
19
  })
13
20
 
21
+ /**
22
+ * Interface for the email payload
23
+ */
14
24
  interface SendEmailParams {
15
25
  to: string
16
26
  subject: string
@@ -18,6 +28,17 @@ interface SendEmailParams {
18
28
  html?: string
19
29
  }
20
30
 
31
+ /**
32
+ * Sends an email using the configured transporter.
33
+ *
34
+ * @param {SendEmailParams} params - The email parameters.
35
+ * @param {string} params.to - The recipient's email address.
36
+ * @param {string} params.subject - The subject of the email.
37
+ * @param {string} [params.text] - The plain text content of the email.
38
+ * @param {string} [params.html] - The HTML content of the email (optional).
39
+ * @returns {Promise<any>} Resolves when the email is sent successfully.
40
+ * @throws {Error} If sending the email fails.
41
+ */
21
42
  export const sendEmail = async ({
22
43
  to,
23
44
  subject,
@@ -38,6 +59,9 @@ export const sendEmail = async ({
38
59
  return info
39
60
  } catch (error) {
40
61
  console.error('Error sending email:', error)
41
- throw new Error('Failed to send email')
62
+ throw new AppError(
63
+ AppErrorCode.INTERNAL,
64
+ 'The email was not sent. Try again.'
65
+ )
42
66
  }
43
67
  }