create-ej-app 25.0.0 → 27.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (385) hide show
  1. package/README.md +125 -226
  2. package/cli.ts +83 -28
  3. package/package.json +2 -2
  4. package/template/api/.vscode/settings.json +1 -1
  5. package/template/api/{.github/copilot-instructions.md → AGENTS.md} +0 -2
  6. package/template/api/CLAUDE.md +1 -0
  7. package/template/api/package.json +10 -10
  8. package/template/nextjs/.dockerignore +1 -0
  9. package/template/nextjs/AGENTS.md +260 -0
  10. package/template/nextjs/CLAUDE.md +1 -0
  11. package/template/nextjs/README.md +109 -0
  12. package/template/nextjs/bunfig.toml +3 -0
  13. package/template/nextjs/eslint.config.mjs +6 -5
  14. package/template/nextjs/gitignore +6 -0
  15. package/template/nextjs/next.config.ts +8 -1
  16. package/template/nextjs/package.json +47 -40
  17. package/template/nextjs/public/logo-dark.svg +4 -0
  18. package/template/nextjs/public/logo.svg +4 -0
  19. package/template/nextjs/src/app/(admin)/actions/users.ts +183 -0
  20. package/template/nextjs/src/app/(admin)/admin/_components/admin-header.tsx +24 -0
  21. package/template/nextjs/src/app/(admin)/admin/_components/admin-search.tsx +16 -0
  22. package/template/nextjs/src/app/(admin)/admin/_components/admin-sidebar.tsx +42 -0
  23. package/template/nextjs/src/app/(admin)/admin/layout.tsx +53 -0
  24. package/template/nextjs/src/app/(admin)/admin/page.tsx +11 -2
  25. package/template/nextjs/src/app/(admin)/admin/settings/layout.tsx +24 -0
  26. package/template/nextjs/src/app/(admin)/admin/settings/users/[userId]/page.tsx +43 -0
  27. package/template/nextjs/src/app/(admin)/admin/settings/users/new/page.tsx +16 -0
  28. package/template/nextjs/src/app/(admin)/admin/settings/users/page.tsx +377 -0
  29. package/template/nextjs/src/app/(admin)/admin/settings/users/user-form.tsx +138 -0
  30. package/template/nextjs/src/app/(admin)/admin/settings/users/validation.ts +23 -0
  31. package/template/nextjs/src/app/(app)/app/layout.tsx +44 -0
  32. package/template/nextjs/src/app/(app)/app/page.tsx +14 -2
  33. package/template/nextjs/src/app/(auth)/(account)/layout.tsx +64 -0
  34. package/template/nextjs/src/app/(auth)/(account)/profile/page.tsx +16 -0
  35. package/template/nextjs/src/app/(auth)/(account)/profile/profile-form.tsx +247 -0
  36. package/template/nextjs/src/app/(auth)/login/email-login-form.tsx +56 -29
  37. package/template/nextjs/src/app/(auth)/login/page.tsx +45 -12
  38. package/template/nextjs/src/app/(auth)/login/social-login-buttons.tsx +85 -17
  39. package/template/nextjs/src/app/(marketing)/layout.tsx +35 -4
  40. package/template/nextjs/src/app/(marketing)/legal.tsx +56 -0
  41. package/template/nextjs/src/app/(marketing)/page.tsx +20 -2
  42. package/template/nextjs/src/app/(marketing)/privacy/page.tsx +43 -2
  43. package/template/nextjs/src/app/(marketing)/terms/page.tsx +33 -2
  44. package/template/nextjs/src/app/error.tsx +37 -0
  45. package/template/nextjs/src/app/fonts/geist-mono/GeistMono-Variable.woff2 +0 -0
  46. package/template/nextjs/src/app/fonts/geist-sans/Geist-Variable.woff2 +0 -0
  47. package/template/nextjs/src/app/fonts/index.ts +60 -0
  48. package/template/nextjs/src/app/global-error.tsx +38 -0
  49. package/template/nextjs/src/app/globals.css +29 -0
  50. package/template/nextjs/src/app/layout.tsx +45 -18
  51. package/template/nextjs/src/app/not-found.tsx +25 -3
  52. package/template/nextjs/src/app/robots.ts +29 -0
  53. package/template/nextjs/src/app/sitemap.ts +21 -0
  54. package/template/nextjs/src/components/confirm-dialog.tsx +79 -0
  55. package/template/nextjs/src/components/dashboard-header.tsx +70 -0
  56. package/template/nextjs/src/components/data-table/enum.ts +5 -0
  57. package/template/nextjs/src/components/data-table/features.ts +49 -0
  58. package/template/nextjs/src/components/data-table/index.tsx +961 -0
  59. package/template/nextjs/src/components/data-table/types.ts +76 -0
  60. package/template/nextjs/src/components/error.tsx +71 -0
  61. package/template/nextjs/src/components/loader.tsx +18 -2
  62. package/template/nextjs/src/components/logo.tsx +49 -0
  63. package/template/nextjs/src/components/navigation-search.tsx +140 -0
  64. package/template/nextjs/src/components/navigation-sidebar.tsx +241 -0
  65. package/template/nextjs/src/components/page-heading.tsx +38 -0
  66. package/template/nextjs/src/components/react-query-provider.tsx +41 -2
  67. package/template/nextjs/src/components/route-error.tsx +55 -0
  68. package/template/nextjs/src/components/screen-size.tsx +36 -0
  69. package/template/nextjs/src/components/site-footer.tsx +20 -4
  70. package/template/nextjs/src/components/theme-provider.tsx +48 -0
  71. package/template/nextjs/src/components/theme-toggle.tsx +58 -5
  72. package/template/nextjs/src/components/ui/alert-dialog.tsx +199 -0
  73. package/template/nextjs/src/components/ui/avatar.tsx +112 -0
  74. package/template/nextjs/src/components/ui/badge.tsx +54 -0
  75. package/template/nextjs/src/components/ui/button.tsx +3 -1
  76. package/template/nextjs/src/components/ui/card.tsx +4 -4
  77. package/template/nextjs/src/components/ui/collapsible.tsx +33 -0
  78. package/template/nextjs/src/components/ui/command.tsx +192 -0
  79. package/template/nextjs/src/components/ui/dialog.tsx +164 -0
  80. package/template/nextjs/src/components/ui/dropdown-menu.tsx +273 -0
  81. package/template/nextjs/src/components/ui/input-group.tsx +156 -0
  82. package/template/nextjs/src/components/ui/select.tsx +195 -0
  83. package/template/nextjs/src/components/ui/sheet.tsx +143 -0
  84. package/template/nextjs/src/components/ui/sidebar.tsx +702 -0
  85. package/template/nextjs/src/components/ui/skeleton.tsx +13 -0
  86. package/template/nextjs/src/components/ui/table.tsx +116 -0
  87. package/template/nextjs/src/components/ui/textarea.tsx +18 -0
  88. package/template/nextjs/src/components/ui/tooltip.tsx +57 -0
  89. package/template/nextjs/src/components/user-menu.tsx +206 -0
  90. package/template/nextjs/src/db/common.ts +10 -0
  91. package/template/nextjs/src/db/index.ts +16 -6
  92. package/template/nextjs/src/db/relations.ts +21 -0
  93. package/template/nextjs/src/db/schema.ts +85 -46
  94. package/template/nextjs/src/emails/components/email-layout.tsx +27 -11
  95. package/template/nextjs/src/emails/magic-link.tsx +15 -13
  96. package/template/nextjs/src/emails/test.tsx +11 -9
  97. package/template/nextjs/src/emails/welcome.tsx +12 -9
  98. package/template/nextjs/src/env.ts +1 -1
  99. package/template/nextjs/src/hooks/use-mobile.ts +14 -12
  100. package/template/nextjs/src/instrumentation.ts +20 -2
  101. package/template/nextjs/src/lib/actions.ts +55 -0
  102. package/template/nextjs/src/lib/admin-navigation.ts +43 -0
  103. package/template/nextjs/src/lib/auth-client.ts +20 -9
  104. package/template/nextjs/src/lib/auth-permissions.ts +61 -0
  105. package/template/nextjs/src/lib/auth.ts +33 -3
  106. package/template/nextjs/src/lib/callback-url.ts +28 -0
  107. package/template/nextjs/src/lib/constants.ts +21 -0
  108. package/template/nextjs/src/lib/delay.ts +10 -0
  109. package/template/nextjs/src/lib/email-service.ts +21 -0
  110. package/template/nextjs/src/lib/errors.ts +74 -0
  111. package/template/nextjs/src/lib/format-currency.ts +22 -24
  112. package/template/nextjs/src/lib/format-date.ts +69 -6
  113. package/template/nextjs/src/lib/nodemailer.ts +27 -3
  114. package/template/nextjs/src/lib/otel.ts +92 -2
  115. package/template/nextjs/src/lib/safe-action-client.ts +60 -9
  116. package/template/nextjs/src/lib/safe-action.ts +152 -16
  117. package/template/nextjs/src/lib/search.ts +16 -0
  118. package/template/nextjs/src/lib/seo.ts +7 -0
  119. package/template/nextjs/src/lib/siteConfig.ts +17 -1
  120. package/template/nextjs/src/lib/toast-message.ts +87 -1
  121. package/template/nextjs/src/lib/validations.ts +30 -0
  122. package/template/nextjs/src/proxy.ts +48 -0
  123. package/template/nextjs/src/scripts/reset-db.ts +22 -0
  124. package/template/nextjs/src/scripts/seed.ts +59 -8
  125. package/template/nextjs/src/scripts/test-mail.ts +28 -20
  126. package/template/nextjs/tests/admin/users.test.ts +64 -0
  127. package/template/nextjs/tests/db/fk-indexes.test.ts +62 -0
  128. package/template/nextjs/tests/db/seed.test.ts +49 -0
  129. package/template/nextjs/tests/helpers/action.ts +71 -0
  130. package/template/nextjs/tests/helpers/fixtures.ts +78 -0
  131. package/template/nextjs/tests/helpers/index.ts +2 -0
  132. package/template/nextjs/tests/lib/callback-url.test.ts +48 -0
  133. package/template/nextjs/tests/lib/client-server-boundary.test.ts +57 -0
  134. package/template/nextjs/tests/lib/errors.test.ts +10 -0
  135. package/template/nextjs/tests/lib/search.test.ts +23 -0
  136. package/template/nextjs/tests/setup/pglite-db.ts +63 -0
  137. package/template/nextjs/tests/setup/preload.ts +95 -0
  138. package/template/nextjs/tsconfig.json +1 -0
  139. package/template/saas-starter/.dockerignore +8 -0
  140. package/template/saas-starter/.env.example +25 -0
  141. package/template/saas-starter/.vscode/extensions.json +11 -0
  142. package/template/saas-starter/.vscode/settings.json +3 -0
  143. package/template/saas-starter/AGENTS.md +302 -0
  144. package/template/saas-starter/CLAUDE.md +1 -0
  145. package/template/saas-starter/Dockerfile +15 -0
  146. package/template/saas-starter/README.md +125 -0
  147. package/template/saas-starter/bunfig.toml +3 -0
  148. package/template/saas-starter/components.json +25 -0
  149. package/template/saas-starter/drizzle.config.ts +10 -0
  150. package/template/saas-starter/eslint.config.mjs +24 -0
  151. package/template/saas-starter/gitignore +14 -0
  152. package/template/saas-starter/next.config.ts +32 -0
  153. package/template/saas-starter/package.json +97 -0
  154. package/template/saas-starter/postcss.config.mjs +7 -0
  155. package/template/saas-starter/prettier.config.mjs +11 -0
  156. package/template/saas-starter/public/logo-dark.svg +4 -0
  157. package/template/saas-starter/public/logo.svg +4 -0
  158. package/template/saas-starter/public/placeholder.svg +1 -0
  159. package/template/saas-starter/src/app/(admin)/actions/contact-leads.ts +51 -0
  160. package/template/saas-starter/src/app/(admin)/actions/feedbacks.ts +91 -0
  161. package/template/saas-starter/src/app/(admin)/actions/users.ts +183 -0
  162. package/template/saas-starter/src/app/(admin)/admin/_components/admin-header.tsx +24 -0
  163. package/template/saas-starter/src/app/(admin)/admin/_components/admin-search.tsx +16 -0
  164. package/template/saas-starter/src/app/(admin)/admin/_components/admin-sidebar.tsx +42 -0
  165. package/template/saas-starter/src/app/(admin)/admin/contact-leads/page.tsx +123 -0
  166. package/template/saas-starter/src/app/(admin)/admin/feedbacks/page.tsx +161 -0
  167. package/template/saas-starter/src/app/(admin)/admin/layout.tsx +53 -0
  168. package/template/saas-starter/src/app/(admin)/admin/page.tsx +60 -0
  169. package/template/saas-starter/src/app/(admin)/admin/settings/layout.tsx +24 -0
  170. package/template/saas-starter/src/app/(admin)/admin/settings/users/[userId]/page.tsx +43 -0
  171. package/template/saas-starter/src/app/(admin)/admin/settings/users/new/page.tsx +16 -0
  172. package/template/saas-starter/src/app/(admin)/admin/settings/users/page.tsx +377 -0
  173. package/template/saas-starter/src/app/(admin)/admin/settings/users/user-form.tsx +138 -0
  174. package/template/saas-starter/src/app/(admin)/admin/settings/users/validation.ts +23 -0
  175. package/template/saas-starter/src/app/(admin)/admin/support-tickets/[ticketId]/page.tsx +17 -0
  176. package/template/saas-starter/src/app/(admin)/admin/support-tickets/page.tsx +5 -0
  177. package/template/saas-starter/src/app/(app)/actions/billing.ts +216 -0
  178. package/template/saas-starter/src/app/(app)/actions/feedbacks.ts +28 -0
  179. package/template/saas-starter/src/app/(app)/actions/feedbacks.validation.ts +12 -0
  180. package/template/saas-starter/src/app/(app)/actions/members.ts +189 -0
  181. package/template/saas-starter/src/app/(app)/actions/organizations.ts +230 -0
  182. package/template/saas-starter/src/app/(app)/actions/organizations.validation.ts +17 -0
  183. package/template/saas-starter/src/app/(app)/app/[orgId]/dashboard/page.tsx +17 -0
  184. package/template/saas-starter/src/app/(app)/app/[orgId]/layout.tsx +31 -0
  185. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/billing-email-form.tsx +92 -0
  186. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/danger-zone.tsx +357 -0
  187. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/general-settings-form.tsx +130 -0
  188. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/plans-section.tsx +222 -0
  189. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/billing/page.tsx +115 -0
  190. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/layout.tsx +24 -0
  191. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/_components/invite-member-dialog.tsx +168 -0
  192. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/_components/members-tabs.tsx +604 -0
  193. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/page.tsx +33 -0
  194. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/page.tsx +55 -0
  195. package/template/saas-starter/src/app/(app)/app/[orgId]/support-tickets/[ticketId]/page.tsx +25 -0
  196. package/template/saas-starter/src/app/(app)/app/[orgId]/support-tickets/page.tsx +25 -0
  197. package/template/saas-starter/src/app/(app)/app/_components/app-header.tsx +31 -0
  198. package/template/saas-starter/src/app/(app)/app/_components/app-search.tsx +26 -0
  199. package/template/saas-starter/src/app/(app)/app/_components/app-sidebar.tsx +30 -0
  200. package/template/saas-starter/src/app/(app)/app/_components/feedback-dialog.tsx +108 -0
  201. package/template/saas-starter/src/app/(app)/app/_components/organization-context.tsx +37 -0
  202. package/template/saas-starter/src/app/(app)/app/_components/organization-switcher.tsx +200 -0
  203. package/template/saas-starter/src/app/(app)/app/layout.tsx +64 -0
  204. package/template/saas-starter/src/app/(app)/app/onboarding/_components/create-organization-form.tsx +117 -0
  205. package/template/saas-starter/src/app/(app)/app/onboarding/page.tsx +40 -0
  206. package/template/saas-starter/src/app/(app)/app/page.tsx +76 -0
  207. package/template/saas-starter/src/app/(auth)/(account)/layout.tsx +64 -0
  208. package/template/saas-starter/src/app/(auth)/(account)/profile/page.tsx +16 -0
  209. package/template/saas-starter/src/app/(auth)/(account)/profile/profile-form.tsx +247 -0
  210. package/template/saas-starter/src/app/(auth)/(account)/support/[ticketId]/page.tsx +19 -0
  211. package/template/saas-starter/src/app/(auth)/(account)/support/page.tsx +9 -0
  212. package/template/saas-starter/src/app/(auth)/login/email-login-form.tsx +131 -0
  213. package/template/saas-starter/src/app/(auth)/login/page.tsx +105 -0
  214. package/template/saas-starter/src/app/(auth)/login/social-login-buttons.tsx +137 -0
  215. package/template/saas-starter/src/app/(invitation)/accept-invitation/[id]/accept-invitation-form.tsx +94 -0
  216. package/template/saas-starter/src/app/(invitation)/accept-invitation/[id]/page.tsx +153 -0
  217. package/template/saas-starter/src/app/(invitation)/actions/invitations.ts +32 -0
  218. package/template/saas-starter/src/app/(marketing)/contact-us/actions.ts +23 -0
  219. package/template/saas-starter/src/app/(marketing)/contact-us/contact-form.tsx +118 -0
  220. package/template/saas-starter/src/app/(marketing)/contact-us/page.tsx +20 -0
  221. package/template/saas-starter/src/app/(marketing)/contact-us/validation.ts +22 -0
  222. package/template/saas-starter/src/app/(marketing)/layout.tsx +48 -0
  223. package/template/saas-starter/src/app/(marketing)/legal.tsx +66 -0
  224. package/template/saas-starter/src/app/(marketing)/page.tsx +24 -0
  225. package/template/saas-starter/src/app/(marketing)/privacy/page.tsx +63 -0
  226. package/template/saas-starter/src/app/(marketing)/terms/page.tsx +41 -0
  227. package/template/saas-starter/src/app/actions/support-tickets.ts +510 -0
  228. package/template/saas-starter/src/app/actions/support-tickets.validation.ts +112 -0
  229. package/template/saas-starter/src/app/actions/uploads.ts +36 -0
  230. package/template/saas-starter/src/app/actions/uploads.validation.ts +24 -0
  231. package/template/saas-starter/src/app/api/auth/[...all]/route.ts +4 -0
  232. package/template/saas-starter/src/app/api/webhooks/stripe/route.ts +94 -0
  233. package/template/saas-starter/src/app/error.tsx +37 -0
  234. package/template/saas-starter/src/app/favicon.ico +0 -0
  235. package/template/saas-starter/src/app/fonts/geist-mono/GeistMono-Variable.woff2 +0 -0
  236. package/template/saas-starter/src/app/fonts/geist-sans/Geist-Variable.woff2 +0 -0
  237. package/template/saas-starter/src/app/fonts/index.ts +60 -0
  238. package/template/saas-starter/src/app/global-error.tsx +38 -0
  239. package/template/saas-starter/src/app/globals.css +269 -0
  240. package/template/saas-starter/src/app/layout.tsx +77 -0
  241. package/template/saas-starter/src/app/not-found.tsx +49 -0
  242. package/template/saas-starter/src/app/robots.ts +36 -0
  243. package/template/saas-starter/src/app/sitemap.ts +21 -0
  244. package/template/saas-starter/src/components/confirm-dialog.tsx +79 -0
  245. package/template/saas-starter/src/components/dashboard-header.tsx +70 -0
  246. package/template/saas-starter/src/components/data-table/enum.ts +5 -0
  247. package/template/saas-starter/src/components/data-table/features.ts +49 -0
  248. package/template/saas-starter/src/components/data-table/index.tsx +961 -0
  249. package/template/saas-starter/src/components/data-table/types.ts +76 -0
  250. package/template/saas-starter/src/components/error.tsx +71 -0
  251. package/template/saas-starter/src/components/file-upload.tsx +254 -0
  252. package/template/saas-starter/src/components/loader.tsx +41 -0
  253. package/template/saas-starter/src/components/logo.tsx +49 -0
  254. package/template/saas-starter/src/components/navigation-search.tsx +140 -0
  255. package/template/saas-starter/src/components/navigation-sidebar.tsx +241 -0
  256. package/template/saas-starter/src/components/page-heading.tsx +38 -0
  257. package/template/saas-starter/src/components/react-query-provider.tsx +51 -0
  258. package/template/saas-starter/src/components/route-error.tsx +55 -0
  259. package/template/saas-starter/src/components/screen-size.tsx +76 -0
  260. package/template/saas-starter/src/components/site-footer.tsx +39 -0
  261. package/template/saas-starter/src/components/support/create-support-ticket-dialog.tsx +122 -0
  262. package/template/saas-starter/src/components/support/support-ticket-conversation.tsx +236 -0
  263. package/template/saas-starter/src/components/support/support-ticket-shared.tsx +58 -0
  264. package/template/saas-starter/src/components/support/support-tickets-table.tsx +281 -0
  265. package/template/saas-starter/src/components/theme-provider.tsx +115 -0
  266. package/template/saas-starter/src/components/theme-toggle.tsx +75 -0
  267. package/template/saas-starter/src/components/ui/alert-dialog.tsx +199 -0
  268. package/template/saas-starter/src/components/ui/avatar.tsx +112 -0
  269. package/template/saas-starter/src/components/ui/badge.tsx +54 -0
  270. package/template/saas-starter/src/components/ui/button.tsx +69 -0
  271. package/template/saas-starter/src/components/ui/card.tsx +103 -0
  272. package/template/saas-starter/src/components/ui/checkbox.tsx +32 -0
  273. package/template/saas-starter/src/components/ui/collapsible.tsx +33 -0
  274. package/template/saas-starter/src/components/ui/command.tsx +192 -0
  275. package/template/saas-starter/src/components/ui/dialog.tsx +164 -0
  276. package/template/saas-starter/src/components/ui/dropdown-menu.tsx +273 -0
  277. package/template/saas-starter/src/components/ui/field.tsx +238 -0
  278. package/template/saas-starter/src/components/ui/input-group.tsx +156 -0
  279. package/template/saas-starter/src/components/ui/input.tsx +19 -0
  280. package/template/saas-starter/src/components/ui/label.tsx +24 -0
  281. package/template/saas-starter/src/components/ui/popover.tsx +89 -0
  282. package/template/saas-starter/src/components/ui/select.tsx +195 -0
  283. package/template/saas-starter/src/components/ui/separator.tsx +28 -0
  284. package/template/saas-starter/src/components/ui/sheet.tsx +143 -0
  285. package/template/saas-starter/src/components/ui/sidebar.tsx +702 -0
  286. package/template/saas-starter/src/components/ui/skeleton.tsx +13 -0
  287. package/template/saas-starter/src/components/ui/sonner.tsx +45 -0
  288. package/template/saas-starter/src/components/ui/switch.tsx +33 -0
  289. package/template/saas-starter/src/components/ui/table.tsx +116 -0
  290. package/template/saas-starter/src/components/ui/tabs.tsx +90 -0
  291. package/template/saas-starter/src/components/ui/textarea.tsx +18 -0
  292. package/template/saas-starter/src/components/ui/tooltip.tsx +57 -0
  293. package/template/saas-starter/src/components/user-menu.tsx +213 -0
  294. package/template/saas-starter/src/db/common.ts +63 -0
  295. package/template/saas-starter/src/db/enums.ts +50 -0
  296. package/template/saas-starter/src/db/index.ts +60 -0
  297. package/template/saas-starter/src/db/relations.ts +112 -0
  298. package/template/saas-starter/src/db/schema.ts +342 -0
  299. package/template/saas-starter/src/emails/components/email-layout.tsx +79 -0
  300. package/template/saas-starter/src/emails/invitation.tsx +69 -0
  301. package/template/saas-starter/src/emails/magic-link.tsx +51 -0
  302. package/template/saas-starter/src/emails/test.tsx +34 -0
  303. package/template/saas-starter/src/emails/welcome.tsx +37 -0
  304. package/template/saas-starter/src/env.ts +66 -0
  305. package/template/saas-starter/src/hooks/use-mobile.ts +21 -0
  306. package/template/saas-starter/src/instrumentation.ts +44 -0
  307. package/template/saas-starter/src/lib/actions.ts +55 -0
  308. package/template/saas-starter/src/lib/admin-navigation.ts +63 -0
  309. package/template/saas-starter/src/lib/app-navigation.ts +63 -0
  310. package/template/saas-starter/src/lib/auth-client.ts +34 -0
  311. package/template/saas-starter/src/lib/auth-permissions.ts +61 -0
  312. package/template/saas-starter/src/lib/auth.ts +312 -0
  313. package/template/saas-starter/src/lib/billing.ts +152 -0
  314. package/template/saas-starter/src/lib/callback-url.ts +28 -0
  315. package/template/saas-starter/src/lib/cn.ts +4 -0
  316. package/template/saas-starter/src/lib/constants.ts +91 -0
  317. package/template/saas-starter/src/lib/delay.ts +12 -0
  318. package/template/saas-starter/src/lib/email-service.ts +118 -0
  319. package/template/saas-starter/src/lib/errors.ts +74 -0
  320. package/template/saas-starter/src/lib/format-currency.ts +25 -0
  321. package/template/saas-starter/src/lib/format-date.ts +75 -0
  322. package/template/saas-starter/src/lib/image-url.ts +54 -0
  323. package/template/saas-starter/src/lib/nodemailer.ts +67 -0
  324. package/template/saas-starter/src/lib/organization-access.ts +73 -0
  325. package/template/saas-starter/src/lib/otel.ts +117 -0
  326. package/template/saas-starter/src/lib/plan-limits.ts +37 -0
  327. package/template/saas-starter/src/lib/plans.ts +90 -0
  328. package/template/saas-starter/src/lib/rbac.ts +44 -0
  329. package/template/saas-starter/src/lib/safe-action-client.ts +126 -0
  330. package/template/saas-starter/src/lib/safe-action.ts +187 -0
  331. package/template/saas-starter/src/lib/search.ts +16 -0
  332. package/template/saas-starter/src/lib/seo.ts +7 -0
  333. package/template/saas-starter/src/lib/siteConfig.ts +20 -0
  334. package/template/saas-starter/src/lib/storage.ts +311 -0
  335. package/template/saas-starter/src/lib/stripe.ts +9 -0
  336. package/template/saas-starter/src/lib/toast-message.ts +101 -0
  337. package/template/saas-starter/src/lib/validations.ts +95 -0
  338. package/template/saas-starter/src/proxy.ts +48 -0
  339. package/template/saas-starter/src/scripts/cleanup-temp-uploads.ts +24 -0
  340. package/template/saas-starter/src/scripts/reset-db.ts +22 -0
  341. package/template/saas-starter/src/scripts/seed.ts +85 -0
  342. package/template/saas-starter/src/scripts/setup-db.ts +72 -0
  343. package/template/saas-starter/src/scripts/setup-stripe-plans.ts +206 -0
  344. package/template/saas-starter/src/scripts/test-mail.ts +43 -0
  345. package/template/saas-starter/tests/admin/users.test.ts +64 -0
  346. package/template/saas-starter/tests/db/fk-indexes.test.ts +62 -0
  347. package/template/saas-starter/tests/db/seed.test.ts +57 -0
  348. package/template/saas-starter/tests/helpers/action.ts +71 -0
  349. package/template/saas-starter/tests/helpers/fixtures.ts +151 -0
  350. package/template/saas-starter/tests/helpers/index.ts +2 -0
  351. package/template/saas-starter/tests/lib/callback-url.test.ts +48 -0
  352. package/template/saas-starter/tests/lib/client-server-boundary.test.ts +57 -0
  353. package/template/saas-starter/tests/lib/errors.test.ts +10 -0
  354. package/template/saas-starter/tests/lib/image-url.test.ts +31 -0
  355. package/template/saas-starter/tests/lib/search.test.ts +23 -0
  356. package/template/saas-starter/tests/marketing/contact-and-feedback.test.ts +86 -0
  357. package/template/saas-starter/tests/organizations/organizations.test.ts +175 -0
  358. package/template/saas-starter/tests/setup/pglite-db.ts +67 -0
  359. package/template/saas-starter/tests/setup/preload.ts +108 -0
  360. package/template/saas-starter/tests/support/support-tickets.test.ts +145 -0
  361. package/template/saas-starter/tsconfig.json +52 -0
  362. package/cli.js +0 -143
  363. package/template/nextjs/src/app/(admin)/layout.tsx +0 -20
  364. package/template/nextjs/src/app/(app)/layout.tsx +0 -20
  365. package/template/nextjs/terraform/artifact-registry.tf +0 -5
  366. package/template/nextjs/terraform/backend-service.tf +0 -13
  367. package/template/nextjs/terraform/cleanup.sh +0 -9
  368. package/template/nextjs/terraform/cloudbuild.tf +0 -70
  369. package/template/nextjs/terraform/cloudrun.tf +0 -68
  370. package/template/nextjs/terraform/database.tf +0 -60
  371. package/template/nextjs/terraform/forwarding-rule.tf +0 -43
  372. package/template/nextjs/terraform/gitignore +0 -4
  373. package/template/nextjs/terraform/ip-address.tf +0 -11
  374. package/template/nextjs/terraform/network-endpoint-group.tf +0 -11
  375. package/template/nextjs/terraform/network.tf +0 -21
  376. package/template/nextjs/terraform/pre.sh +0 -3
  377. package/template/nextjs/terraform/providers.tf +0 -18
  378. package/template/nextjs/terraform/secret.txt +0 -11
  379. package/template/nextjs/terraform/setup.sh +0 -25
  380. package/template/nextjs/terraform/ssl-certs.tf +0 -32
  381. package/template/nextjs/terraform/target-https-proxy copy.tf +0 -14
  382. package/template/nextjs/terraform/target-https-proxy.tf +0 -14
  383. package/template/nextjs/terraform/terraform.tfvars +0 -4
  384. package/template/nextjs/terraform/url-map.tf +0 -23
  385. package/template/nextjs/terraform/variables.tf +0 -21
@@ -0,0 +1,60 @@
1
+ import 'server-only'
2
+
3
+ import { drizzle } from 'drizzle-orm/postgres-js'
4
+ import postgres from 'postgres'
5
+ import { env } from '~/env'
6
+ import * as relations from './relations'
7
+ import * as schema from './schema'
8
+
9
+ // In development, Next.js hot-reloading can re-evaluate modules frequently.
10
+ // Keep a single postgres-js client on `globalThis` to avoid opening multiple
11
+ // database connections during local development.
12
+ const globalForDb = globalThis as unknown as {
13
+ client?: ReturnType<typeof postgres>
14
+ }
15
+
16
+ // Prefer the cached client (dev) and fall back to creating a new one.
17
+ // `prepare: false` disables prepared statements, which transaction-mode
18
+ // poolers such as PgBouncer and Neon's pooler reject.
19
+ const client =
20
+ globalForDb.client ??
21
+ postgres(env.DATABASE_URL, {
22
+ prepare: false,
23
+ // For neon.tech postgres db, as its serverless
24
+ ...(env.DATABASE_URL.includes('neon.tech')
25
+ ? {
26
+ max: 5,
27
+ idle_timeout: 30, // seconds an idle connection is kept before closing
28
+ max_lifetime: 60 * 5, // recycle a connection after 5 minutes
29
+ connect_timeout: 60 // seconds to wait for a connection (Neon cold start)
30
+ }
31
+ : {})
32
+ })
33
+
34
+ // Only cache the client outside production to avoid unexpected cross-request
35
+ // state retention in long-lived production processes.
36
+ if (env.APP_ENV !== 'production') globalForDb.client = client
37
+
38
+ /**
39
+ * Drizzle ORM database client for PostgreSQL.
40
+ *
41
+ * Backed by a postgres-js client configured via `env.DATABASE_URL`, and wired
42
+ * up with schema + relations so you get typed query helpers.
43
+ *
44
+ * Use this instance throughout the app for all database operations.
45
+ *
46
+ * @example
47
+ * const users = await db.query.usersTable.findMany()
48
+ * const newUser = await db.insert(usersTable).values({ email: 'user@example.com' })
49
+ *
50
+ * @example
51
+ * await db.transaction(async (tx) => {
52
+ * await tx.insert(usersTable).values({ email: 'user@example.com' })
53
+ * })
54
+ */
55
+ export const db = drizzle(client, {
56
+ schema: { ...schema, ...relations }
57
+ })
58
+
59
+ /** The transaction client type passed into `db.transaction(async (tx) => ...)`. */
60
+ export type DbTx = Parameters<Parameters<(typeof db)['transaction']>[0]>[0]
@@ -0,0 +1,112 @@
1
+ import { relations } from 'drizzle-orm'
2
+ import {
3
+ accountsTable,
4
+ feedbacksTable,
5
+ invitationsTable,
6
+ membersTable,
7
+ organizationSubscriptionsTable,
8
+ organizationsTable,
9
+ sessionsTable,
10
+ supportTicketMessagesTable,
11
+ supportTicketsTable,
12
+ usersTable
13
+ } from './schema'
14
+
15
+ export const userRelations = relations(usersTable, ({ many }) => ({
16
+ sessions: many(sessionsTable),
17
+ accounts: many(accountsTable),
18
+ members: many(membersTable),
19
+ invitations: many(invitationsTable)
20
+ }))
21
+
22
+ export const sessionRelations = relations(sessionsTable, ({ one }) => ({
23
+ user: one(usersTable, {
24
+ fields: [sessionsTable.userId],
25
+ references: [usersTable.id]
26
+ })
27
+ }))
28
+
29
+ export const accountRelations = relations(accountsTable, ({ one }) => ({
30
+ user: one(usersTable, {
31
+ fields: [accountsTable.userId],
32
+ references: [usersTable.id]
33
+ })
34
+ }))
35
+
36
+ export const organizationRelations = relations(
37
+ organizationsTable,
38
+ ({ many, one }) => ({
39
+ subscription: one(organizationSubscriptionsTable, {
40
+ fields: [organizationsTable.id],
41
+ references: [organizationSubscriptionsTable.organizationId]
42
+ }),
43
+ members: many(membersTable),
44
+ invitations: many(invitationsTable),
45
+ supportTickets: many(supportTicketsTable),
46
+ feedbacks: many(feedbacksTable)
47
+ })
48
+ )
49
+
50
+ export const memberRelations = relations(membersTable, ({ one }) => ({
51
+ organization: one(organizationsTable, {
52
+ fields: [membersTable.organizationId],
53
+ references: [organizationsTable.id]
54
+ }),
55
+ user: one(usersTable, {
56
+ fields: [membersTable.userId],
57
+ references: [usersTable.id]
58
+ })
59
+ }))
60
+
61
+ export const invitationRelations = relations(invitationsTable, ({ one }) => ({
62
+ organization: one(organizationsTable, {
63
+ fields: [invitationsTable.organizationId],
64
+ references: [organizationsTable.id]
65
+ }),
66
+ inviter: one(usersTable, {
67
+ fields: [invitationsTable.inviterId],
68
+ references: [usersTable.id]
69
+ })
70
+ }))
71
+
72
+ export const organizationSubscriptionRelations = relations(
73
+ organizationSubscriptionsTable,
74
+ ({ one }) => ({
75
+ organization: one(organizationsTable, {
76
+ fields: [organizationSubscriptionsTable.organizationId],
77
+ references: [organizationsTable.id]
78
+ })
79
+ })
80
+ )
81
+
82
+ export const supportTicketRelations = relations(
83
+ supportTicketsTable,
84
+ ({ one, many }) => ({
85
+ organization: one(organizationsTable, {
86
+ fields: [supportTicketsTable.organizationId],
87
+ references: [organizationsTable.id]
88
+ }),
89
+ user: one(usersTable, {
90
+ fields: [supportTicketsTable.userId],
91
+ references: [usersTable.id]
92
+ }),
93
+ messages: many(supportTicketMessagesTable)
94
+ })
95
+ )
96
+
97
+ export const supportTicketMessageRelations = relations(
98
+ supportTicketMessagesTable,
99
+ ({ one }) => ({
100
+ ticket: one(supportTicketsTable, {
101
+ fields: [supportTicketMessagesTable.ticketId],
102
+ references: [supportTicketsTable.id]
103
+ })
104
+ })
105
+ )
106
+
107
+ export const feedbackRelations = relations(feedbacksTable, ({ one }) => ({
108
+ organization: one(organizationsTable, {
109
+ fields: [feedbacksTable.organizationId],
110
+ references: [organizationsTable.id]
111
+ })
112
+ }))
@@ -0,0 +1,342 @@
1
+ /**
2
+ * @fileoverview Drizzle table definitions.
3
+ *
4
+ * Enums live in `./enums.ts` and reusable column helpers in `./common.ts`.
5
+ * Every foreign key leads an index and declares an explicit `onDelete`;
6
+ * `tests/db/fk-indexes.test.ts` enforces both.
7
+ */
8
+ import { sql } from 'drizzle-orm'
9
+ import {
10
+ boolean,
11
+ check,
12
+ index,
13
+ integer,
14
+ jsonb,
15
+ pgTable,
16
+ text,
17
+ uniqueIndex,
18
+ varchar
19
+ } from 'drizzle-orm/pg-core'
20
+ import { commonFieldDefs, nonNegativeCheck } from './common'
21
+ import {
22
+ organizationSubscriptionIntervalEnum,
23
+ organizationSubscriptionPlanEnum,
24
+ organizationSubscriptionStatusEnum,
25
+ supportTicketPriorityEnum,
26
+ supportTicketStatusEnum
27
+ } from './enums'
28
+
29
+ export * from './enums'
30
+
31
+ // ─── Auth (Better Auth) ─────────────────────────────────────────
32
+
33
+ export const usersTable = pgTable(
34
+ 'users',
35
+ {
36
+ id: commonFieldDefs.id('user'),
37
+ name: text('name').notNull(),
38
+ email: text('email').notNull().unique(),
39
+ emailVerified: boolean('email_verified').default(false).notNull(),
40
+ image: text('image'),
41
+ role: text('role'),
42
+ banned: boolean('banned').default(false),
43
+ banReason: text('ban_reason'),
44
+ banExpires: commonFieldDefs.date('ban_expires'),
45
+ ...commonFieldDefs.dates
46
+ },
47
+ table => [index().on(table.createdAt), index().on(table.role)]
48
+ )
49
+
50
+ export const sessionsTable = pgTable(
51
+ 'sessions',
52
+ {
53
+ id: commonFieldDefs.id('session'),
54
+ expiresAt: commonFieldDefs.date('expires_at').notNull(),
55
+ token: text('token').notNull().unique(),
56
+ ipAddress: text('ip_address'),
57
+ userAgent: text('user_agent'),
58
+ userId: text('user_id')
59
+ .notNull()
60
+ .references(() => usersTable.id, { onDelete: 'cascade' }),
61
+ impersonatedBy: text('impersonated_by'),
62
+ activeOrganizationId: text('active_organization_id'),
63
+ ...commonFieldDefs.dates
64
+ },
65
+ table => [index().on(table.userId)]
66
+ )
67
+
68
+ export const accountsTable = pgTable(
69
+ 'accounts',
70
+ {
71
+ id: commonFieldDefs.id('account'),
72
+ accountId: text('account_id').notNull(),
73
+ providerId: text('provider_id').notNull(),
74
+ userId: text('user_id')
75
+ .notNull()
76
+ .references(() => usersTable.id, { onDelete: 'cascade' }),
77
+ accessToken: text('access_token'),
78
+ refreshToken: text('refresh_token'),
79
+ idToken: text('id_token'),
80
+ accessTokenExpiresAt: commonFieldDefs.date('access_token_expires_at'),
81
+ refreshTokenExpiresAt: commonFieldDefs.date('refresh_token_expires_at'),
82
+ scope: text('scope'),
83
+ password: text('password'),
84
+ ...commonFieldDefs.dates
85
+ },
86
+ table => [index().on(table.userId)]
87
+ )
88
+
89
+ export const verificationsTable = pgTable(
90
+ 'verifications',
91
+ {
92
+ id: commonFieldDefs.id('verification'),
93
+ identifier: text('identifier').notNull(),
94
+ value: text('value').notNull(),
95
+ expiresAt: commonFieldDefs.date('expires_at').notNull(),
96
+ ...commonFieldDefs.dates
97
+ },
98
+ table => [index().on(table.identifier)]
99
+ )
100
+
101
+ // ─── Organizations (Better Auth) ────────────────────────────────
102
+
103
+ export const organizationsTable = pgTable('organizations', {
104
+ id: commonFieldDefs.id('organization'),
105
+ name: text('name').notNull(),
106
+ slug: text('slug').notNull().unique(),
107
+ logo: text('logo'),
108
+ metadata: text('metadata'),
109
+ ...commonFieldDefs.dates
110
+ })
111
+
112
+ export const membersTable = pgTable(
113
+ 'members',
114
+ {
115
+ id: commonFieldDefs.id('member'),
116
+ organizationId: text('organization_id')
117
+ .notNull()
118
+ .references(() => organizationsTable.id, { onDelete: 'cascade' }),
119
+ userId: text('user_id')
120
+ .notNull()
121
+ .references(() => usersTable.id, { onDelete: 'cascade' }),
122
+ role: text('role').notNull(),
123
+ ...commonFieldDefs.dates
124
+ },
125
+ table => [
126
+ uniqueIndex().on(table.organizationId, table.userId),
127
+ index().on(table.userId)
128
+ ]
129
+ )
130
+
131
+ export const invitationsTable = pgTable(
132
+ 'invitations',
133
+ {
134
+ id: commonFieldDefs.id('invitation'),
135
+ organizationId: text('organization_id')
136
+ .notNull()
137
+ .references(() => organizationsTable.id, { onDelete: 'cascade' }),
138
+ email: text('email').notNull(),
139
+ role: text('role'),
140
+ status: text('status').default('pending').notNull(),
141
+ expiresAt: commonFieldDefs.date('expires_at'),
142
+ inviterId: text('inviter_id')
143
+ .notNull()
144
+ .references(() => usersTable.id, { onDelete: 'cascade' }),
145
+ ...commonFieldDefs.dates
146
+ },
147
+ table => [
148
+ index().on(table.organizationId),
149
+ index().on(table.email),
150
+ index().on(table.inviterId),
151
+ uniqueIndex()
152
+ .on(table.organizationId, table.email)
153
+ .where(sql`${table.status} = 'pending'`)
154
+ ]
155
+ )
156
+
157
+ // ─── Billing ────────────────────────────────────────────────────
158
+
159
+ export const organizationSubscriptionsTable = pgTable(
160
+ 'organization_subscriptions',
161
+ {
162
+ id: commonFieldDefs.id('organization_subscription'),
163
+ organizationId: text('organization_id')
164
+ .notNull()
165
+ .unique()
166
+ .references(() => organizationsTable.id, { onDelete: 'cascade' }),
167
+ plan: organizationSubscriptionPlanEnum('plan').notNull().default('free'),
168
+ status: organizationSubscriptionStatusEnum('status')
169
+ .notNull()
170
+ .default('free'),
171
+ interval: organizationSubscriptionIntervalEnum('interval'),
172
+ billingEmail: text('billing_email'),
173
+ stripeCustomerId: text('stripe_customer_id').unique(),
174
+ stripeSubscriptionId: text('stripe_subscription_id').unique(),
175
+ hasUsedTrial: boolean('has_used_trial').notNull().default(false),
176
+ trialEndsAt: commonFieldDefs.date('trial_ends_at'),
177
+ currentPeriodEnd: commonFieldDefs.date('current_period_end'),
178
+ cancelAtPeriodEnd: boolean('cancel_at_period_end').notNull().default(false),
179
+ ...commonFieldDefs.dates
180
+ }
181
+ )
182
+
183
+ /** One row per Stripe event, so a redelivered webhook is processed once. */
184
+ export const stripeWebhooksEventsTable = pgTable('stripe_webhooks_events', {
185
+ id: text('id').primaryKey(),
186
+ type: text('type').notNull(),
187
+ receivedAt: commonFieldDefs.date('received_at').notNull().defaultNow(),
188
+ processed: boolean('processed').notNull().default(false),
189
+ processedAt: commonFieldDefs.date('processed_at'),
190
+ error: text('error'),
191
+ // Stored only when processing fails, for replay and debugging.
192
+ payload: jsonb('payload')
193
+ })
194
+
195
+ // ─── Support ────────────────────────────────────────────────────
196
+
197
+ /** Single-row counter behind `TICKET-000001` numbers; see `scripts/setup-db.ts`. */
198
+ export const supportTicketCounterTable = pgTable(
199
+ 'support_ticket_counter',
200
+ {
201
+ id: integer('id').primaryKey().default(1),
202
+ counter: integer('counter').notNull().default(0)
203
+ },
204
+ table => [
205
+ nonNegativeCheck('support_ticket_counter_counter_non_negative', [
206
+ table.counter
207
+ ])
208
+ ]
209
+ )
210
+
211
+ /**
212
+ * A ticket with an `organizationId` belongs to that organization's managers.
213
+ * A ticket without one is an account ticket, visible only to its author.
214
+ * Platform admins see both.
215
+ */
216
+ export const supportTicketsTable = pgTable(
217
+ 'support_tickets',
218
+ {
219
+ id: commonFieldDefs.id('support_ticket'),
220
+ // The database fills this in from a trigger; see `scripts/setup-db.ts`.
221
+ ticketNumber: varchar('ticket_number')
222
+ .notNull()
223
+ .unique()
224
+ .$defaultFn(() => ''),
225
+ organizationId: varchar('organization_id').references(
226
+ () => organizationsTable.id,
227
+ { onDelete: 'set null' }
228
+ ),
229
+ userId: varchar('user_id').references(() => usersTable.id, {
230
+ onDelete: 'set null'
231
+ }),
232
+ subject: varchar('subject').notNull(),
233
+ status: supportTicketStatusEnum('status').notNull().default('open'),
234
+ priority: supportTicketPriorityEnum('priority'),
235
+ resolvedAt: commonFieldDefs.date('resolved_at'),
236
+ ...commonFieldDefs.dates
237
+ },
238
+ table => [
239
+ index().on(table.userId, table.createdAt),
240
+ index().on(table.organizationId, table.createdAt),
241
+ index().on(table.status),
242
+ index().on(table.priority)
243
+ ]
244
+ )
245
+
246
+ export const supportTicketMessagesTable = pgTable(
247
+ 'support_ticket_messages',
248
+ {
249
+ id: commonFieldDefs.id('support_ticket_message'),
250
+ ticketId: varchar('ticket_id')
251
+ .notNull()
252
+ .references(() => supportTicketsTable.id, { onDelete: 'cascade' }),
253
+ userId: varchar('user_id').references(() => usersTable.id, {
254
+ onDelete: 'set null'
255
+ }),
256
+ message: text('message').notNull(),
257
+ /** True when the support team wrote it, even if the author is also a customer. */
258
+ isAdmin: boolean('is_admin').notNull().default(false),
259
+ ...commonFieldDefs.dates
260
+ },
261
+ table => [index().on(table.ticketId), index().on(table.userId)]
262
+ )
263
+
264
+ export const feedbacksTable = pgTable(
265
+ 'feedbacks',
266
+ {
267
+ id: commonFieldDefs.id('feedback'),
268
+ userId: varchar('user_id')
269
+ .notNull()
270
+ .references(() => usersTable.id, { onDelete: 'cascade' }),
271
+ organizationId: varchar('organization_id')
272
+ .notNull()
273
+ .references(() => organizationsTable.id, { onDelete: 'cascade' }),
274
+ rating: integer('rating').notNull(),
275
+ comment: text('comment'),
276
+ ...commonFieldDefs.dates
277
+ },
278
+ table => [
279
+ index().on(table.userId),
280
+ index().on(table.organizationId, table.createdAt),
281
+ index().on(table.createdAt),
282
+ check('feedbacks_rating_range', sql`${table.rating} BETWEEN 1 AND 5`)
283
+ ]
284
+ )
285
+
286
+ export const contactLeadsTable = pgTable(
287
+ 'contact_leads',
288
+ {
289
+ id: commonFieldDefs.id('contact_lead'),
290
+ name: varchar('name').notNull(),
291
+ email: varchar('email').notNull(),
292
+ phone: varchar('phone'),
293
+ subject: varchar('subject').notNull(),
294
+ message: text('message').notNull(),
295
+ ...commonFieldDefs.dates
296
+ },
297
+ table => [index().on(table.createdAt)]
298
+ )
299
+
300
+ // ─── Uploads ────────────────────────────────────────────────────
301
+
302
+ export const fileUploadsTable = pgTable(
303
+ 'file_uploads',
304
+ {
305
+ id: commonFieldDefs.id('file_upload'),
306
+ // Object key, such as uploads/organizations/<organizationId>/<file>.jpg
307
+ key: text('key').notNull().unique(),
308
+ originalName: varchar('original_name').notNull(),
309
+ mimeType: varchar('mime_type').notNull(),
310
+ // In bytes
311
+ size: integer('size').notNull(),
312
+ isTemp: boolean('is_temp').notNull().default(true),
313
+ uploadedBy: varchar('uploaded_by').references(() => usersTable.id, {
314
+ onDelete: 'set null'
315
+ }),
316
+ // `restrict` forces organization deletion to remove the R2 objects first;
317
+ // see `deleteOrganizationUploads`.
318
+ organizationId: varchar('organization_id')
319
+ .notNull()
320
+ .references(() => organizationsTable.id, { onDelete: 'restrict' }),
321
+ ...commonFieldDefs.dates
322
+ },
323
+ table => [
324
+ index().on(table.uploadedBy),
325
+ index().on(table.organizationId),
326
+ index().on(table.isTemp, table.createdAt),
327
+ nonNegativeCheck('file_uploads_size_non_negative', [table.size])
328
+ ]
329
+ )
330
+
331
+ // ─── Seed ───────────────────────────────────────────────────────
332
+
333
+ /**
334
+ * Singleton marker for a successful `seedDatabase` run. While
335
+ * {@link SEED_RUN_MARKER_ID} exists, the seed script does nothing.
336
+ */
337
+ export const SEED_RUN_MARKER_ID = 'seed_run_default'
338
+
339
+ export const seedRunsTable = pgTable('seed_runs', {
340
+ id: varchar('id').primaryKey(),
341
+ ...commonFieldDefs.dates
342
+ })
@@ -0,0 +1,79 @@
1
+ /**
2
+ * @fileoverview Shared email shell: a brand rule and name at the top, the
3
+ * content, and a copyright footer. Every template wraps its body in it.
4
+ *
5
+ * @module emails/components/email-layout
6
+ */
7
+
8
+ import {
9
+ Body,
10
+ Container,
11
+ Head,
12
+ Html,
13
+ pixelBasedPreset,
14
+ Preview,
15
+ Section,
16
+ Tailwind,
17
+ Text
18
+ } from 'react-email'
19
+
20
+ /** Brand color. Email clients strip CSS variables, so it is a literal here. */
21
+ const BRAND = '#171717'
22
+
23
+ interface EmailLayoutProps {
24
+ /** Text email clients show in the inbox before the email is opened. */
25
+ previewText: string
26
+ companyName: string
27
+ children: React.ReactNode
28
+ }
29
+
30
+ export default function EmailLayout({
31
+ previewText,
32
+ companyName,
33
+ children
34
+ }: EmailLayoutProps) {
35
+ return (
36
+ <Html>
37
+ <Head />
38
+ <Preview>{previewText}</Preview>
39
+ <Tailwind
40
+ config={{
41
+ presets: [pixelBasedPreset],
42
+ theme: { extend: { colors: { brand: BRAND } } }
43
+ }}
44
+ >
45
+ <Body className="bg-gray-100 font-sans">
46
+ <Container className="mx-auto my-8 max-w-xl rounded-lg bg-white shadow-sm">
47
+ <Section
48
+ className="rounded-t-lg bg-white px-8 py-5"
49
+ style={{
50
+ borderTop: `4px solid ${BRAND}`,
51
+ borderBottom: '1px solid #E5E7EB'
52
+ }}
53
+ >
54
+ <Text className="m-0 text-lg font-bold tracking-tight text-gray-900">
55
+ {companyName}
56
+ </Text>
57
+ </Section>
58
+
59
+ <Section className="px-8 py-6">{children}</Section>
60
+
61
+ <Section
62
+ className="rounded-b-lg bg-gray-50 px-8 py-6"
63
+ style={{ borderTop: '1px solid #E5E7EB' }}
64
+ >
65
+ <Text className="m-0 text-center text-sm text-gray-500">
66
+ &copy; {new Date().getUTCFullYear()} {companyName}. All rights
67
+ reserved.
68
+ </Text>
69
+ <Text className="m-0 mt-2 text-center text-xs text-gray-400">
70
+ This is an automated email. Replies to this address are not
71
+ read.
72
+ </Text>
73
+ </Section>
74
+ </Container>
75
+ </Body>
76
+ </Tailwind>
77
+ </Html>
78
+ )
79
+ }
@@ -0,0 +1,69 @@
1
+ import { Button, Heading, Text } from 'react-email'
2
+ import EmailLayout from '~/emails/components/email-layout'
3
+ import { INVITATION_EXPIRES_IN_DAYS } from '~/lib/constants'
4
+ import { roleArticle } from '~/lib/rbac'
5
+ import { siteConfig } from '~/lib/siteConfig'
6
+
7
+ export interface InvitationEmailProps {
8
+ inviteUrl: string
9
+ organizationName: string
10
+ inviterName: string
11
+ role: string
12
+ companyName: string
13
+ }
14
+
15
+ /** Invitation to join an organization, sent by the Better Auth organization plugin. */
16
+ export default function InvitationEmail({
17
+ inviteUrl,
18
+ organizationName,
19
+ inviterName,
20
+ role,
21
+ companyName
22
+ }: InvitationEmailProps) {
23
+ return (
24
+ <EmailLayout
25
+ previewText={`${inviterName} invited you to join ${organizationName}`}
26
+ companyName={companyName}
27
+ >
28
+ <Heading className="mb-4 text-2xl font-bold text-gray-900">
29
+ You&apos;re Invited to Join {organizationName}
30
+ </Heading>
31
+
32
+ <Text className="mb-4 text-base text-gray-700">
33
+ {inviterName} invited you to join <strong>{organizationName}</strong> on{' '}
34
+ {companyName} as {roleArticle(role)}.
35
+ </Text>
36
+
37
+ <Text className="mb-4 text-base text-gray-700">
38
+ Select the button below to accept. You&apos;ll sign in with this email
39
+ address first.
40
+ </Text>
41
+
42
+ <Button
43
+ href={inviteUrl}
44
+ className="bg-brand mb-4 rounded-lg px-6 py-3 text-center text-base font-semibold text-white no-underline"
45
+ >
46
+ Accept Invitation
47
+ </Button>
48
+
49
+ <Text className="mb-4 text-sm text-gray-600">
50
+ Or copy this link into your browser:
51
+ </Text>
52
+
53
+ <Text className="text-brand mb-4 text-sm break-all">{inviteUrl}</Text>
54
+
55
+ <Text className="text-sm text-gray-500">
56
+ This invitation expires in {INVITATION_EXPIRES_IN_DAYS} days. If you
57
+ weren&apos;t expecting it, you can ignore this email.
58
+ </Text>
59
+ </EmailLayout>
60
+ )
61
+ }
62
+
63
+ InvitationEmail.PreviewProps = {
64
+ inviteUrl: 'https://example.com/accept-invitation/your_invitation_id_here',
65
+ organizationName: 'Acme Inc.',
66
+ inviterName: 'Alex Morgan',
67
+ role: 'member',
68
+ companyName: siteConfig.name
69
+ } satisfies InvitationEmailProps