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,510 @@
1
+ 'use server'
2
+
3
+ import {
4
+ and,
5
+ asc,
6
+ count,
7
+ desc,
8
+ eq,
9
+ ilike,
10
+ inArray,
11
+ isNull,
12
+ or
13
+ } from 'drizzle-orm'
14
+ import { z } from 'zod'
15
+ import { SortOrderEnum } from '~/components/data-table/enum'
16
+ import { db } from '~/db'
17
+ import {
18
+ organizationsTable,
19
+ supportTicketMessagesTable,
20
+ supportTicketsTable,
21
+ usersTable
22
+ } from '~/db/schema'
23
+ import { getPaginationSchema } from '~/lib/actions'
24
+ import { AppError, AppErrorCode } from '~/lib/errors'
25
+ import { assertCanManageOrganization } from '~/lib/organization-access'
26
+ import {
27
+ adminActionClient,
28
+ authActionClient,
29
+ withWideEvent
30
+ } from '~/lib/safe-action'
31
+ import { likeContains } from '~/lib/search'
32
+ import { optionalStringValidation } from '~/lib/validations'
33
+ import {
34
+ ACCOUNT_TICKET_FILTER_VALUE,
35
+ addSupportTicketMessageSchema,
36
+ createSupportTicketSchema,
37
+ listAccountSupportTicketsSchema,
38
+ listAdminSupportTicketsSchema,
39
+ listOrganizationSupportTicketsSchema,
40
+ supportTicketIdSchema,
41
+ updateSupportTicketStatusSchema
42
+ } from './support-tickets.validation'
43
+
44
+ /**
45
+ * Tickets come in two shapes.
46
+ *
47
+ * An **organization ticket** has an `organizationId`. Every owner and admin
48
+ * of that organization reads and answers it, whoever opened the thread;
49
+ * members have no access. An **account ticket** has none, and belongs to the person who raised
50
+ * it alone, so any signed-in user can open one about their own account.
51
+ *
52
+ * Platform admins see both.
53
+ */
54
+
55
+ const ticketSelect = {
56
+ id: supportTicketsTable.id,
57
+ ticketNumber: supportTicketsTable.ticketNumber,
58
+ subject: supportTicketsTable.subject,
59
+ status: supportTicketsTable.status,
60
+ priority: supportTicketsTable.priority,
61
+ createdAt: supportTicketsTable.createdAt,
62
+ resolvedAt: supportTicketsTable.resolvedAt,
63
+ userId: supportTicketsTable.userId,
64
+ userEmail: usersTable.email,
65
+ userName: usersTable.name,
66
+ organizationId: supportTicketsTable.organizationId,
67
+ organizationName: organizationsTable.name
68
+ }
69
+
70
+ const orderBy = (sortBy: string | undefined, sortOrder: string | undefined) => {
71
+ const column = supportTicketsTable[(sortBy || 'createdAt') as 'createdAt']
72
+ return sortOrder === SortOrderEnum.ASC ? asc(column) : desc(column)
73
+ }
74
+
75
+ /**
76
+ * Build the admin list's organization filter. The sentinel selects account
77
+ * tickets, which `inArray` cannot express, so the two halves are OR'd.
78
+ */
79
+ function organizationFilter(selected: string[] | undefined) {
80
+ if (!selected?.length) return undefined
81
+
82
+ const organizationIds = selected.filter(
83
+ value => value !== ACCOUNT_TICKET_FILTER_VALUE
84
+ )
85
+ const conditions = [
86
+ ...(selected.includes(ACCOUNT_TICKET_FILTER_VALUE)
87
+ ? [isNull(supportTicketsTable.organizationId)]
88
+ : []),
89
+ ...(organizationIds.length
90
+ ? [inArray(supportTicketsTable.organizationId, organizationIds)]
91
+ : [])
92
+ ]
93
+
94
+ return or(...conditions)
95
+ }
96
+
97
+ /**
98
+ * Load a ticket and assert the caller may read it: a platform admin, a manager
99
+ * of the organization it belongs to, or the author of an account ticket.
100
+ */
101
+ async function assertTicketAccess(
102
+ ticketId: string,
103
+ ctx: { user: { id: string }; isAdmin?: boolean }
104
+ ) {
105
+ const ticket = await db.query.supportTicketsTable.findFirst({
106
+ where: eq(supportTicketsTable.id, ticketId),
107
+ columns: { id: true, userId: true, status: true, organizationId: true }
108
+ })
109
+
110
+ if (!ticket)
111
+ throw new AppError(AppErrorCode.NOT_FOUND, 'Support ticket not found.')
112
+
113
+ // Branch explicitly. A missing organizationId means "author only", never
114
+ // "skip the check".
115
+ if (!ctx.isAdmin) {
116
+ if (ticket.organizationId) {
117
+ await assertCanManageOrganization(ticket.organizationId, ctx.user.id)
118
+ } else if (ticket.userId !== ctx.user.id) {
119
+ throw new AppError(
120
+ AppErrorCode.FORBIDDEN,
121
+ 'You do not have permission to do this.'
122
+ )
123
+ }
124
+ }
125
+
126
+ return ticket
127
+ }
128
+
129
+ export const getOrganizationSupportTicketsAction = authActionClient
130
+ .inputSchema(listOrganizationSupportTicketsSchema)
131
+ .action(
132
+ withWideEvent(
133
+ 'getOrganizationSupportTicketsAction',
134
+ async ({
135
+ parsedInput: {
136
+ organizationId,
137
+ page,
138
+ limit,
139
+ sortBy,
140
+ sortOrder,
141
+ search,
142
+ filters
143
+ },
144
+ ctx
145
+ }) => {
146
+ await assertCanManageOrganization(organizationId, ctx.user.id)
147
+
148
+ const where = and(
149
+ eq(supportTicketsTable.organizationId, organizationId),
150
+ search
151
+ ? or(
152
+ ilike(supportTicketsTable.ticketNumber, likeContains(search)),
153
+ ilike(supportTicketsTable.subject, likeContains(search))
154
+ )
155
+ : undefined,
156
+ filters?.status?.length
157
+ ? inArray(supportTicketsTable.status, filters.status)
158
+ : undefined,
159
+ filters?.priority?.length
160
+ ? inArray(supportTicketsTable.priority, filters.priority)
161
+ : undefined
162
+ )
163
+
164
+ const [results, total] = await Promise.all([
165
+ db
166
+ .select(ticketSelect)
167
+ .from(supportTicketsTable)
168
+ .leftJoin(usersTable, eq(supportTicketsTable.userId, usersTable.id))
169
+ .leftJoin(
170
+ organizationsTable,
171
+ eq(supportTicketsTable.organizationId, organizationsTable.id)
172
+ )
173
+ .where(where)
174
+ .limit(limit)
175
+ .offset((page - 1) * limit)
176
+ .orderBy(orderBy(sortBy, sortOrder)),
177
+ db.select({ count: count() }).from(supportTicketsTable).where(where)
178
+ ])
179
+
180
+ return [results, total[0]?.count || 0] as const
181
+ }
182
+ )
183
+ )
184
+
185
+ /**
186
+ * The caller's own account tickets. Organization tickets are deliberately absent:
187
+ * they live in their organization's shared queue, so each ticket appears in
188
+ * exactly one list.
189
+ */
190
+ export const getMyAccountSupportTicketsAction = authActionClient
191
+ .inputSchema(listAccountSupportTicketsSchema)
192
+ .action(
193
+ withWideEvent(
194
+ 'getMyAccountSupportTicketsAction',
195
+ async ({
196
+ parsedInput: { page, limit, sortBy, sortOrder, search, filters },
197
+ ctx
198
+ }) => {
199
+ const where = and(
200
+ eq(supportTicketsTable.userId, ctx.user.id),
201
+ isNull(supportTicketsTable.organizationId),
202
+ search
203
+ ? or(
204
+ ilike(supportTicketsTable.ticketNumber, likeContains(search)),
205
+ ilike(supportTicketsTable.subject, likeContains(search))
206
+ )
207
+ : undefined,
208
+ filters?.status?.length
209
+ ? inArray(supportTicketsTable.status, filters.status)
210
+ : undefined,
211
+ filters?.priority?.length
212
+ ? inArray(supportTicketsTable.priority, filters.priority)
213
+ : undefined
214
+ )
215
+
216
+ const [results, total] = await Promise.all([
217
+ db
218
+ .select(ticketSelect)
219
+ .from(supportTicketsTable)
220
+ .leftJoin(usersTable, eq(supportTicketsTable.userId, usersTable.id))
221
+ .leftJoin(
222
+ organizationsTable,
223
+ eq(supportTicketsTable.organizationId, organizationsTable.id)
224
+ )
225
+ .where(where)
226
+ .limit(limit)
227
+ .offset((page - 1) * limit)
228
+ .orderBy(orderBy(sortBy, sortOrder)),
229
+ db.select({ count: count() }).from(supportTicketsTable).where(where)
230
+ ])
231
+
232
+ return [results, total[0]?.count || 0] as const
233
+ }
234
+ )
235
+ )
236
+
237
+ export const getAdminSupportTicketsAction = adminActionClient
238
+ .inputSchema(listAdminSupportTicketsSchema)
239
+ .action(
240
+ withWideEvent(
241
+ 'getAdminSupportTicketsAction',
242
+ async ({
243
+ parsedInput: { page, limit, sortBy, sortOrder, search, filters }
244
+ }) => {
245
+ const where = and(
246
+ search
247
+ ? or(
248
+ ilike(supportTicketsTable.ticketNumber, likeContains(search)),
249
+ ilike(supportTicketsTable.subject, likeContains(search)),
250
+ ilike(usersTable.email, likeContains(search)),
251
+ ilike(usersTable.name, likeContains(search)),
252
+ ilike(organizationsTable.name, likeContains(search))
253
+ )
254
+ : undefined,
255
+ filters?.status?.length
256
+ ? inArray(supportTicketsTable.status, filters.status)
257
+ : undefined,
258
+ filters?.priority?.length
259
+ ? inArray(supportTicketsTable.priority, filters.priority)
260
+ : undefined,
261
+ organizationFilter(filters?.organizationId)
262
+ )
263
+
264
+ const [results, total] = await Promise.all([
265
+ db
266
+ .select(ticketSelect)
267
+ .from(supportTicketsTable)
268
+ .leftJoin(usersTable, eq(supportTicketsTable.userId, usersTable.id))
269
+ .leftJoin(
270
+ organizationsTable,
271
+ eq(supportTicketsTable.organizationId, organizationsTable.id)
272
+ )
273
+ .where(where)
274
+ .limit(limit)
275
+ .offset((page - 1) * limit)
276
+ .orderBy(orderBy(sortBy, sortOrder)),
277
+ db
278
+ .select({ count: count() })
279
+ .from(supportTicketsTable)
280
+ .leftJoin(usersTable, eq(supportTicketsTable.userId, usersTable.id))
281
+ .leftJoin(
282
+ organizationsTable,
283
+ eq(supportTicketsTable.organizationId, organizationsTable.id)
284
+ )
285
+ .where(where)
286
+ ])
287
+
288
+ return [results, total[0]?.count || 0] as const
289
+ }
290
+ )
291
+ )
292
+
293
+ /** Organizations that have at least one ticket, for the admin list filter. */
294
+ export const getSupportTicketOrganizationsAction = adminActionClient
295
+ .inputSchema(
296
+ z.object({
297
+ search: optionalStringValidation,
298
+ ...getPaginationSchema(20)
299
+ })
300
+ )
301
+ .action(
302
+ withWideEvent(
303
+ 'getSupportTicketOrganizationsAction',
304
+ async ({ parsedInput: { search, limit } }) => {
305
+ const rows = await db
306
+ .selectDistinct({
307
+ value: organizationsTable.id,
308
+ label: organizationsTable.name
309
+ })
310
+ .from(supportTicketsTable)
311
+ .innerJoin(
312
+ organizationsTable,
313
+ eq(supportTicketsTable.organizationId, organizationsTable.id)
314
+ )
315
+ .where(
316
+ search
317
+ ? ilike(organizationsTable.name, likeContains(search))
318
+ : undefined
319
+ )
320
+ .orderBy(asc(organizationsTable.name))
321
+ .limit(limit)
322
+
323
+ const [accountTicket] = await db
324
+ .select({ id: supportTicketsTable.id })
325
+ .from(supportTicketsTable)
326
+ .where(isNull(supportTicketsTable.organizationId))
327
+ .limit(1)
328
+
329
+ return accountTicket
330
+ ? [
331
+ {
332
+ value: ACCOUNT_TICKET_FILTER_VALUE,
333
+ label: 'Account (no organization)'
334
+ },
335
+ ...rows
336
+ ]
337
+ : rows
338
+ }
339
+ )
340
+ )
341
+
342
+ export const getSupportTicketDetailsAction = authActionClient
343
+ .inputSchema(supportTicketIdSchema)
344
+ .action(
345
+ withWideEvent(
346
+ 'getSupportTicketDetailsAction',
347
+ async ({ parsedInput: { ticketId }, ctx }) => {
348
+ await assertTicketAccess(ticketId, ctx)
349
+
350
+ const [ticket] = await db
351
+ .select(ticketSelect)
352
+ .from(supportTicketsTable)
353
+ .leftJoin(usersTable, eq(supportTicketsTable.userId, usersTable.id))
354
+ .leftJoin(
355
+ organizationsTable,
356
+ eq(supportTicketsTable.organizationId, organizationsTable.id)
357
+ )
358
+ .where(eq(supportTicketsTable.id, ticketId))
359
+
360
+ if (!ticket)
361
+ throw new AppError(
362
+ AppErrorCode.NOT_FOUND,
363
+ 'Support ticket not found.'
364
+ )
365
+
366
+ const messages = await db
367
+ .select({
368
+ id: supportTicketMessagesTable.id,
369
+ ticketId: supportTicketMessagesTable.ticketId,
370
+ userId: supportTicketMessagesTable.userId,
371
+ message: supportTicketMessagesTable.message,
372
+ isAdmin: supportTicketMessagesTable.isAdmin,
373
+ createdAt: supportTicketMessagesTable.createdAt,
374
+ userName: usersTable.name,
375
+ userEmail: usersTable.email
376
+ })
377
+ .from(supportTicketMessagesTable)
378
+ .leftJoin(
379
+ usersTable,
380
+ eq(supportTicketMessagesTable.userId, usersTable.id)
381
+ )
382
+ .where(eq(supportTicketMessagesTable.ticketId, ticketId))
383
+ .orderBy(asc(supportTicketMessagesTable.createdAt))
384
+
385
+ return { ticket, messages }
386
+ }
387
+ )
388
+ )
389
+
390
+ export const createSupportTicketAction = authActionClient
391
+ .inputSchema(createSupportTicketSchema)
392
+ .action(
393
+ withWideEvent('createSupportTicketAction', async ({ parsedInput, ctx }) => {
394
+ // Only an organization ticket needs a membership check. An account ticket is
395
+ // owned by whoever raises it, so any signed-in user may open one.
396
+ if (parsedInput.organizationId) {
397
+ await assertCanManageOrganization(
398
+ parsedInput.organizationId,
399
+ ctx.user.id
400
+ )
401
+ }
402
+
403
+ const [ticket] = await db
404
+ .insert(supportTicketsTable)
405
+ .values({
406
+ organizationId: parsedInput.organizationId ?? null,
407
+ userId: ctx.user.id,
408
+ subject: parsedInput.subject,
409
+ priority: parsedInput.priority,
410
+ status: 'open'
411
+ })
412
+ .returning()
413
+
414
+ if (!ticket)
415
+ throw new AppError(
416
+ AppErrorCode.INTERNAL,
417
+ 'The support ticket was not created. Try again.'
418
+ )
419
+
420
+ await db.insert(supportTicketMessagesTable).values({
421
+ ticketId: ticket.id,
422
+ userId: ctx.user.id,
423
+ message: parsedInput.message,
424
+ isAdmin: false
425
+ })
426
+
427
+ return ticket
428
+ })
429
+ )
430
+
431
+ export const addSupportTicketMessageAction = authActionClient
432
+ .inputSchema(addSupportTicketMessageSchema)
433
+ .action(
434
+ withWideEvent(
435
+ 'addSupportTicketMessageAction',
436
+ async ({ parsedInput, ctx }) => {
437
+ const ticket = await assertTicketAccess(parsedInput.ticketId, ctx)
438
+ // A platform admin replying from the admin panel is the support team.
439
+ // The same person replying from their own ticket page is the customer.
440
+ const asSupport = Boolean(ctx.isAdmin) && parsedInput.asSupport
441
+
442
+ if (!asSupport && ['closed', 'resolved'].includes(ticket.status)) {
443
+ throw new AppError(
444
+ AppErrorCode.BAD_REQUEST,
445
+ 'Resolved or closed support tickets cannot receive customer messages.'
446
+ )
447
+ }
448
+
449
+ const [message] = await db
450
+ .insert(supportTicketMessagesTable)
451
+ .values({
452
+ ticketId: ticket.id,
453
+ userId: ctx.user.id,
454
+ message: parsedInput.message,
455
+ isAdmin: asSupport
456
+ })
457
+ .returning()
458
+
459
+ if (asSupport) {
460
+ await db
461
+ .update(supportTicketsTable)
462
+ .set({
463
+ status: 'waiting_customer',
464
+ resolvedAt: null,
465
+ updatedAt: new Date()
466
+ })
467
+ .where(eq(supportTicketsTable.id, ticket.id))
468
+ } else if (ticket.status === 'waiting_customer') {
469
+ await db
470
+ .update(supportTicketsTable)
471
+ .set({
472
+ status: 'in_progress',
473
+ updatedAt: new Date()
474
+ })
475
+ .where(eq(supportTicketsTable.id, ticket.id))
476
+ }
477
+
478
+ return message
479
+ }
480
+ )
481
+ )
482
+
483
+ export const updateSupportTicketStatusAction = adminActionClient
484
+ .inputSchema(updateSupportTicketStatusSchema)
485
+ .action(
486
+ withWideEvent(
487
+ 'updateSupportTicketStatusAction',
488
+ async ({ parsedInput }) => {
489
+ const [ticket] = await db
490
+ .update(supportTicketsTable)
491
+ .set({
492
+ status: parsedInput.status,
493
+ resolvedAt: ['resolved', 'closed'].includes(parsedInput.status)
494
+ ? new Date()
495
+ : null,
496
+ updatedAt: new Date()
497
+ })
498
+ .where(eq(supportTicketsTable.id, parsedInput.ticketId))
499
+ .returning()
500
+
501
+ if (!ticket)
502
+ throw new AppError(
503
+ AppErrorCode.NOT_FOUND,
504
+ 'Support ticket not found.'
505
+ )
506
+
507
+ return ticket
508
+ }
509
+ )
510
+ )
@@ -0,0 +1,112 @@
1
+ import { z } from 'zod'
2
+ import { getPaginationSchema, getSortSchema } from '~/lib/actions'
3
+ import { MAX_FILTER_VALUES } from '~/lib/constants'
4
+ import {
5
+ optionalStringValidation,
6
+ requiredMessageValidation,
7
+ stringValidation
8
+ } from '~/lib/validations'
9
+
10
+ export const supportTicketStatusSchema = z.enum([
11
+ 'open',
12
+ 'in_progress',
13
+ 'waiting_customer',
14
+ 'resolved',
15
+ 'closed'
16
+ ])
17
+
18
+ export const supportTicketPrioritySchema = z.enum([
19
+ 'low',
20
+ 'medium',
21
+ 'high',
22
+ 'urgent'
23
+ ])
24
+
25
+ /**
26
+ * Omit `organizationId` to raise an account ticket, visible only to its author
27
+ * and platform admins. Supply it to raise an organization ticket, which every
28
+ * manager of that organization can read and answer. An empty string is coerced
29
+ * to null so a blank form field cannot masquerade as an organization id.
30
+ */
31
+ export const createSupportTicketSchema = z.object({
32
+ organizationId: z
33
+ .string()
34
+ .trim()
35
+ .transform(value => value || null)
36
+ .nullish(),
37
+ subject: stringValidation,
38
+ priority: supportTicketPrioritySchema.default('medium'),
39
+ message: requiredMessageValidation
40
+ })
41
+
42
+ export const addSupportTicketMessageSchema = z.object({
43
+ ticketId: stringValidation,
44
+ message: requiredMessageValidation,
45
+ /** Reply as the support team. Ignored unless the caller is a platform admin. */
46
+ asSupport: z.boolean().default(false)
47
+ })
48
+
49
+ export const updateSupportTicketStatusSchema = z.object({
50
+ ticketId: stringValidation,
51
+ status: supportTicketStatusSchema
52
+ })
53
+
54
+ const listSortKeys = [
55
+ 'ticketNumber',
56
+ 'subject',
57
+ 'status',
58
+ 'priority',
59
+ 'createdAt',
60
+ 'resolvedAt'
61
+ ] as const
62
+
63
+ /** Shared list shape. The admin list adds an organization filter on top. */
64
+ const baseListSchema = {
65
+ ...getPaginationSchema(),
66
+ ...getSortSchema([...listSortKeys]),
67
+ search: optionalStringValidation,
68
+ filters: z
69
+ .object({
70
+ status: z
71
+ .array(supportTicketStatusSchema)
72
+ .max(MAX_FILTER_VALUES)
73
+ .optional(),
74
+ priority: z
75
+ .array(supportTicketPrioritySchema)
76
+ .max(MAX_FILTER_VALUES)
77
+ .optional()
78
+ })
79
+ .optional()
80
+ }
81
+
82
+ export const listOrganizationSupportTicketsSchema = z.object({
83
+ organizationId: stringValidation,
84
+ ...baseListSchema
85
+ })
86
+
87
+ export const listAccountSupportTicketsSchema = z.object(baseListSchema)
88
+
89
+ /**
90
+ * Sentinel for the admin organization filter. `inArray` cannot express IS NULL,
91
+ * so account tickets are selected by this value instead of an organization id.
92
+ */
93
+ export const ACCOUNT_TICKET_FILTER_VALUE = 'account'
94
+
95
+ export const listAdminSupportTicketsSchema = z.object({
96
+ ...baseListSchema,
97
+ filters: z
98
+ .object({
99
+ status: z
100
+ .array(supportTicketStatusSchema)
101
+ .max(MAX_FILTER_VALUES)
102
+ .optional(),
103
+ priority: z
104
+ .array(supportTicketPrioritySchema)
105
+ .max(MAX_FILTER_VALUES)
106
+ .optional(),
107
+ organizationId: z.array(z.string()).max(MAX_FILTER_VALUES).optional()
108
+ })
109
+ .optional()
110
+ })
111
+
112
+ export const supportTicketIdSchema = z.object({ ticketId: stringValidation })
@@ -0,0 +1,36 @@
1
+ 'use server'
2
+
3
+ import { assertCanManageOrganization } from '~/lib/organization-access'
4
+ import { authActionClient, withWideEvent } from '~/lib/safe-action'
5
+ import { generateUploadUrl, isR2Configured } from '~/lib/storage'
6
+ import { generateUploadUrlSchema } from './uploads.validation'
7
+
8
+ /**
9
+ * Starts an upload for an organization the caller manages. Without R2 the
10
+ * client falls back to a base64 data URL, which the saving action accepts
11
+ * only while R2 stays unconfigured.
12
+ */
13
+ export const generateUploadUrlAction = authActionClient
14
+ .inputSchema(generateUploadUrlSchema)
15
+ .action(
16
+ withWideEvent(
17
+ 'generateUploadUrlAction',
18
+ async ({ parsedInput, ctx: { user } }) => {
19
+ await assertCanManageOrganization(parsedInput.organizationId, user.id)
20
+
21
+ if (!isR2Configured()) {
22
+ return { mode: 'base64' as const }
23
+ }
24
+
25
+ const { uploadUrl, key } = await generateUploadUrl({
26
+ filename: parsedInput.filename,
27
+ mimeType: parsedInput.mimeType,
28
+ size: parsedInput.size,
29
+ uploadedBy: user.id,
30
+ organizationId: parsedInput.organizationId
31
+ })
32
+
33
+ return { mode: 'r2' as const, uploadUrl, key }
34
+ }
35
+ )
36
+ )
@@ -0,0 +1,24 @@
1
+ import { z } from 'zod'
2
+ import {
3
+ MAX_FILE_SIZE_BYTES,
4
+ MAX_FILE_SIZE_MB,
5
+ R2_UPLOAD_EXTENSION_BY_MIME_TYPE,
6
+ type R2UploadMimeType
7
+ } from '~/lib/constants'
8
+ import { stringValidation } from '~/lib/validations'
9
+
10
+ const R2_UPLOAD_MIME_TYPES = Object.keys(R2_UPLOAD_EXTENSION_BY_MIME_TYPE) as [
11
+ R2UploadMimeType,
12
+ ...R2UploadMimeType[]
13
+ ]
14
+
15
+ export const generateUploadUrlSchema = z.object({
16
+ filename: stringValidation,
17
+ mimeType: z.enum(R2_UPLOAD_MIME_TYPES),
18
+ size: z
19
+ .number()
20
+ .int()
21
+ .positive()
22
+ .max(MAX_FILE_SIZE_BYTES, `Choose a file under ${MAX_FILE_SIZE_MB} MB.`),
23
+ organizationId: stringValidation
24
+ })
@@ -0,0 +1,4 @@
1
+ import { toNextJsHandler } from 'better-auth/next-js'
2
+ import { auth } from '~/lib/auth'
3
+
4
+ export const { GET, POST } = toNextJsHandler(auth.handler)