create-nexst-monorepo 0.1.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 (303) hide show
  1. package/dist/index.js +1727 -0
  2. package/package.json +37 -0
  3. package/template/.agents/plugins/marketplace.json +21 -0
  4. package/template/.claude/settings.json +6 -0
  5. package/template/.claude/skills/frontend-design/LICENSE.txt +177 -0
  6. package/template/.claude/skills/frontend-design/SKILL.md +154 -0
  7. package/template/.claude/skills/theme-factory/SKILL.md +95 -0
  8. package/template/.claude/skills/theme-factory/references/schema.md +104 -0
  9. package/template/.claude/skills/theme-factory/scripts/theme.mjs +261 -0
  10. package/template/.dockerignore +43 -0
  11. package/template/.editorconfig +10 -0
  12. package/template/.github/workflows/ci.yaml +289 -0
  13. package/template/.github/workflows/publish-app.yaml +66 -0
  14. package/template/.github/workflows/publish.yaml +148 -0
  15. package/template/.github/workflows/snyk.yaml +34 -0
  16. package/template/.vscode/settings.json +8 -0
  17. package/template/AGENTS.md +42 -0
  18. package/template/Dockerfile.base +9 -0
  19. package/template/README.md +54 -0
  20. package/template/_gitignore +10 -0
  21. package/template/apps/api/.env.example +32 -0
  22. package/template/apps/api/AGENTS.md +44 -0
  23. package/template/apps/api/Dockerfile +69 -0
  24. package/template/apps/api/_gitignore +8 -0
  25. package/template/apps/api/knip.config.ts +22 -0
  26. package/template/apps/api/kustomize/base/application.properties +16 -0
  27. package/template/apps/api/kustomize/base/deployment.yaml +94 -0
  28. package/template/apps/api/kustomize/base/kustomization.yaml +17 -0
  29. package/template/apps/api/kustomize/base/service.yaml +10 -0
  30. package/template/apps/api/kustomize/overlays/prod/kustomization.yaml +17 -0
  31. package/template/apps/api/kustomize/overlays/staging/application.properties +1 -0
  32. package/template/apps/api/kustomize/overlays/staging/kustomization.yaml +21 -0
  33. package/template/apps/api/kustomize/secret.keys +14 -0
  34. package/template/apps/api/oxfmt.config.ts +8 -0
  35. package/template/apps/api/oxlint.config.ts +107 -0
  36. package/template/apps/api/package.json +79 -0
  37. package/template/apps/api/prisma/migrations/20260927000000_init/migration.sql +113 -0
  38. package/template/apps/api/prisma/migrations/migration_lock.toml +3 -0
  39. package/template/apps/api/prisma/schema.prisma +113 -0
  40. package/template/apps/api/prisma.config.ts +7 -0
  41. package/template/apps/api/scripts/auth.cli.ts +19 -0
  42. package/template/apps/api/scripts/seal-secret.sh +171 -0
  43. package/template/apps/api/sonar-project.properties +9 -0
  44. package/template/apps/api/src/app-info/app-info.dto.ts +10 -0
  45. package/template/apps/api/src/app-info/app-info.module.ts +7 -0
  46. package/template/apps/api/src/app-info/app-info.resolver.spec.ts +14 -0
  47. package/template/apps/api/src/app-info/app-info.resolver.ts +21 -0
  48. package/template/apps/api/src/app.module.spec.ts +93 -0
  49. package/template/apps/api/src/app.module.ts +77 -0
  50. package/template/apps/api/src/auth/auth.guard.spec.ts +90 -0
  51. package/template/apps/api/src/auth/auth.guard.ts +97 -0
  52. package/template/apps/api/src/auth/auth.ts +129 -0
  53. package/template/apps/api/src/auth/decorator/allow-anonymous.decorator.ts +17 -0
  54. package/template/apps/api/src/auth/decorator/is-email-verified.decorator.spec.ts +41 -0
  55. package/template/apps/api/src/auth/decorator/is-email-verified.decorator.ts +20 -0
  56. package/template/apps/api/src/auth/decorator/roles.decorator.ts +27 -0
  57. package/template/apps/api/src/auth/testing/make-session.ts +31 -0
  58. package/template/apps/api/src/auth/types/auth.types.ts +27 -0
  59. package/template/apps/api/src/bootstrap/register-graphql-enums.ts +12 -0
  60. package/template/apps/api/src/bootstrap/security.ts +24 -0
  61. package/template/apps/api/src/bootstrap/swagger.ts +28 -0
  62. package/template/apps/api/src/config/app.config.spec.ts +22 -0
  63. package/template/apps/api/src/config/app.config.ts +122 -0
  64. package/template/apps/api/src/config/graphql-depth-limit.ts +103 -0
  65. package/template/apps/api/src/config/graphql.config.ts +77 -0
  66. package/template/apps/api/src/config/resources/app.yaml +29 -0
  67. package/template/apps/api/src/errors/app-parse-uuid.pipe.ts +27 -0
  68. package/template/apps/api/src/errors/app.exception.ts +47 -0
  69. package/template/apps/api/src/errors/error-code.enum.ts +54 -0
  70. package/template/apps/api/src/errors/http-status-fallback-code.util.ts +27 -0
  71. package/template/apps/api/src/errors/validation-exception.factory.ts +43 -0
  72. package/template/apps/api/src/health/health.controller.spec.ts +82 -0
  73. package/template/apps/api/src/health/health.controller.ts +57 -0
  74. package/template/apps/api/src/health/health.module.ts +12 -0
  75. package/template/apps/api/src/logging/context-logger.ts +18 -0
  76. package/template/apps/api/src/logging/logger.config.ts +23 -0
  77. package/template/apps/api/src/logging/logging.module.ts +22 -0
  78. package/template/apps/api/src/mail/mail.module.ts +12 -0
  79. package/template/apps/api/src/mail/mail.service.spec.ts +47 -0
  80. package/template/apps/api/src/mail/mail.service.ts +123 -0
  81. package/template/apps/api/src/main.ts +41 -0
  82. package/template/apps/api/src/prisma/prisma.module.ts +12 -0
  83. package/template/apps/api/src/prisma/prisma.service.spec.ts +136 -0
  84. package/template/apps/api/src/prisma/prisma.service.ts +138 -0
  85. package/template/apps/api/src/queue/dto/enqueue-example.input.ts +11 -0
  86. package/template/apps/api/src/queue/example-queue.resolver.ts +14 -0
  87. package/template/apps/api/src/queue/example-queue.service.spec.ts +21 -0
  88. package/template/apps/api/src/queue/example-queue.service.ts +32 -0
  89. package/template/apps/api/src/queue/queue.module.ts +27 -0
  90. package/template/apps/api/src/r2/r2.module.ts +12 -0
  91. package/template/apps/api/src/r2/r2.service.spec.ts +117 -0
  92. package/template/apps/api/src/r2/r2.service.ts +131 -0
  93. package/template/apps/api/src/user/dto/user.dto.ts +26 -0
  94. package/template/apps/api/src/user/user.module.ts +10 -0
  95. package/template/apps/api/src/user/user.resolver.spec.ts +18 -0
  96. package/template/apps/api/src/user/user.resolver.ts +24 -0
  97. package/template/apps/api/src/utils/catch-everything-exception.filter.spec.ts +292 -0
  98. package/template/apps/api/src/utils/catch-everything-exception.filter.ts +70 -0
  99. package/template/apps/api/src/utils/error-handler.util.spec.ts +20 -0
  100. package/template/apps/api/src/utils/error-handler.util.ts +12 -0
  101. package/template/apps/api/src/utils/escape-like-pattern.util.spec.ts +29 -0
  102. package/template/apps/api/src/utils/escape-like-pattern.util.ts +11 -0
  103. package/template/apps/api/src/utils/pagination.input.ts +20 -0
  104. package/template/apps/api/src/utils/prisma-exception.filter.spec.ts +186 -0
  105. package/template/apps/api/src/utils/prisma-exception.filter.ts +45 -0
  106. package/template/apps/api/src/utils/prisma-graphql-exception.filter.spec.ts +216 -0
  107. package/template/apps/api/src/utils/prisma-graphql-exception.filter.ts +43 -0
  108. package/template/apps/api/tests/app.e2e-spec.ts +35 -0
  109. package/template/apps/api/tests/auth.e2e-spec.ts +38 -0
  110. package/template/apps/api/tests/helpers/api.ts +27 -0
  111. package/template/apps/api/tsconfig.json +11 -0
  112. package/template/apps/api/vitest.config.e2e.ts +16 -0
  113. package/template/apps/api/vitest.config.ts +40 -0
  114. package/template/apps/web/.env.example +7 -0
  115. package/template/apps/web/.storybook/decorators.tsx +38 -0
  116. package/template/apps/web/.storybook/main.ts +17 -0
  117. package/template/apps/web/.storybook/preview.ts +38 -0
  118. package/template/apps/web/.storybook/vitest.setup.ts +5 -0
  119. package/template/apps/web/AGENTS.md +29 -0
  120. package/template/apps/web/Dockerfile +49 -0
  121. package/template/apps/web/_gitignore +16 -0
  122. package/template/apps/web/knip.config.ts +15 -0
  123. package/template/apps/web/kustomize/base/application.properties +5 -0
  124. package/template/apps/web/kustomize/base/deployment.yaml +60 -0
  125. package/template/apps/web/kustomize/base/kustomization.yaml +17 -0
  126. package/template/apps/web/kustomize/base/service.yaml +10 -0
  127. package/template/apps/web/kustomize/overlays/prod/kustomization.yaml +12 -0
  128. package/template/apps/web/kustomize/overlays/staging/application.properties +2 -0
  129. package/template/apps/web/kustomize/overlays/staging/kustomization.yaml +18 -0
  130. package/template/apps/web/messages/de.json +124 -0
  131. package/template/apps/web/messages/en.json +124 -0
  132. package/template/apps/web/next-env.d.ts +7 -0
  133. package/template/apps/web/next.config.ts +39 -0
  134. package/template/apps/web/oxfmt.config.ts +9 -0
  135. package/template/apps/web/oxlint.config.ts +36 -0
  136. package/template/apps/web/package.json +80 -0
  137. package/template/apps/web/playwright.config.ts +34 -0
  138. package/template/apps/web/postcss.config.mjs +7 -0
  139. package/template/apps/web/sonar-project.properties +9 -0
  140. package/template/apps/web/src/app/[locale]/(auth)/recovery/page.tsx +22 -0
  141. package/template/apps/web/src/app/[locale]/(auth)/reset-password/page.tsx +29 -0
  142. package/template/apps/web/src/app/[locale]/(auth)/sign-in/page.tsx +24 -0
  143. package/template/apps/web/src/app/[locale]/(auth)/sign-up/page.tsx +24 -0
  144. package/template/apps/web/src/app/[locale]/(auth)/verify/page.tsx +24 -0
  145. package/template/apps/web/src/app/[locale]/dashboard/page.tsx +34 -0
  146. package/template/apps/web/src/app/[locale]/layout.tsx +60 -0
  147. package/template/apps/web/src/app/[locale]/page.tsx +23 -0
  148. package/template/apps/web/src/app/api/health/route.ts +3 -0
  149. package/template/apps/web/src/app/global-error.tsx +19 -0
  150. package/template/apps/web/src/app/globals.css +98 -0
  151. package/template/apps/web/src/app/icon.svg +1 -0
  152. package/template/apps/web/src/app/robots.ts +15 -0
  153. package/template/apps/web/src/app/sitemap.ts +24 -0
  154. package/template/apps/web/src/components/alert/alert.stories.tsx +25 -0
  155. package/template/apps/web/src/components/alert/alert.tsx +50 -0
  156. package/template/apps/web/src/components/avatar/avatar.stories.tsx +116 -0
  157. package/template/apps/web/src/components/avatar/avatar.tsx +88 -0
  158. package/template/apps/web/src/components/button/button-variants.ts +28 -0
  159. package/template/apps/web/src/components/button/button.stories.tsx +33 -0
  160. package/template/apps/web/src/components/button/button.tsx +32 -0
  161. package/template/apps/web/src/components/checkbox/checkbox.stories.tsx +312 -0
  162. package/template/apps/web/src/components/checkbox/checkbox.tsx +38 -0
  163. package/template/apps/web/src/components/dialog/danger-panel.stories.tsx +41 -0
  164. package/template/apps/web/src/components/dialog/danger-panel.tsx +32 -0
  165. package/template/apps/web/src/components/dialog/dialog.stories.tsx +239 -0
  166. package/template/apps/web/src/components/dialog/dialog.tsx +136 -0
  167. package/template/apps/web/src/components/dialog-buttons.stories.tsx +55 -0
  168. package/template/apps/web/src/components/dialog-buttons.tsx +48 -0
  169. package/template/apps/web/src/components/drawer/drawer.stories.tsx +251 -0
  170. package/template/apps/web/src/components/drawer/drawer.tsx +120 -0
  171. package/template/apps/web/src/components/dropdown-menu/dropdown-menu.stories.tsx +329 -0
  172. package/template/apps/web/src/components/dropdown-menu/dropdown-menu.tsx +263 -0
  173. package/template/apps/web/src/components/empty-state.stories.tsx +49 -0
  174. package/template/apps/web/src/components/empty-state.tsx +33 -0
  175. package/template/apps/web/src/components/field-group.stories.tsx +47 -0
  176. package/template/apps/web/src/components/field-group.tsx +20 -0
  177. package/template/apps/web/src/components/field-label.stories.tsx +34 -0
  178. package/template/apps/web/src/components/field-label.tsx +28 -0
  179. package/template/apps/web/src/components/icons.tsx +25 -0
  180. package/template/apps/web/src/components/input/input-otp.stories.tsx +86 -0
  181. package/template/apps/web/src/components/input/input-otp.tsx +184 -0
  182. package/template/apps/web/src/components/input/input.stories.tsx +35 -0
  183. package/template/apps/web/src/components/input/input.tsx +180 -0
  184. package/template/apps/web/src/components/popover/popover.stories.tsx +479 -0
  185. package/template/apps/web/src/components/popover/popover.tsx +45 -0
  186. package/template/apps/web/src/components/responsive-dialog/responsive-dialog.stories.tsx +307 -0
  187. package/template/apps/web/src/components/responsive-dialog/responsive-dialog.tsx +96 -0
  188. package/template/apps/web/src/components/searchbar/searchbar.stories.tsx +59 -0
  189. package/template/apps/web/src/components/searchbar/searchbar.tsx +64 -0
  190. package/template/apps/web/src/components/select/select.stories.tsx +293 -0
  191. package/template/apps/web/src/components/select/select.tsx +445 -0
  192. package/template/apps/web/src/components/skeleton/skeleton.stories.tsx +76 -0
  193. package/template/apps/web/src/components/skeleton/skeleton.tsx +13 -0
  194. package/template/apps/web/src/components/spinner/spinner.tsx +18 -0
  195. package/template/apps/web/src/components/switch/switch.stories.tsx +71 -0
  196. package/template/apps/web/src/components/switch/switch.tsx +35 -0
  197. package/template/apps/web/src/components/tabs/tabs.stories.tsx +209 -0
  198. package/template/apps/web/src/components/tabs/tabs.tsx +69 -0
  199. package/template/apps/web/src/components/tabs/url-tabs.stories.tsx +68 -0
  200. package/template/apps/web/src/components/tabs/url-tabs.tsx +42 -0
  201. package/template/apps/web/src/components/textarea/textarea.stories.tsx +144 -0
  202. package/template/apps/web/src/components/textarea/textarea.tsx +115 -0
  203. package/template/apps/web/src/components/toast/toaster.tsx +38 -0
  204. package/template/apps/web/src/components/tooltip/tooltip.stories.tsx +237 -0
  205. package/template/apps/web/src/components/tooltip/tooltip.tsx +49 -0
  206. package/template/apps/web/src/config/csp.ts +59 -0
  207. package/template/apps/web/src/config/routes.ts +14 -0
  208. package/template/apps/web/src/config/site.ts +5 -0
  209. package/template/apps/web/src/env.ts +15 -0
  210. package/template/apps/web/src/features/auth/auth-card.tsx +20 -0
  211. package/template/apps/web/src/features/auth/auth-divider.tsx +14 -0
  212. package/template/apps/web/src/features/auth/google-auth-button.tsx +49 -0
  213. package/template/apps/web/src/features/auth/google-one-tap.tsx +23 -0
  214. package/template/apps/web/src/features/auth/lib/auth-error.test.ts +22 -0
  215. package/template/apps/web/src/features/auth/lib/auth-error.ts +16 -0
  216. package/template/apps/web/src/features/auth/lib/return-to.test.ts +41 -0
  217. package/template/apps/web/src/features/auth/lib/return-to.ts +19 -0
  218. package/template/apps/web/src/features/auth/lib/validator.test.ts +45 -0
  219. package/template/apps/web/src/features/auth/lib/validator.ts +49 -0
  220. package/template/apps/web/src/features/auth/passkey-settings.tsx +106 -0
  221. package/template/apps/web/src/features/auth/recovery-form.tsx +61 -0
  222. package/template/apps/web/src/features/auth/resend-verification-button.tsx +36 -0
  223. package/template/apps/web/src/features/auth/reset-password-form.tsx +62 -0
  224. package/template/apps/web/src/features/auth/sign-in-form.stories.tsx +22 -0
  225. package/template/apps/web/src/features/auth/sign-in-form.tsx +116 -0
  226. package/template/apps/web/src/features/auth/sign-out-button.tsx +26 -0
  227. package/template/apps/web/src/features/auth/sign-up-form.tsx +107 -0
  228. package/template/apps/web/src/features/shared/provider.tsx +16 -0
  229. package/template/apps/web/src/features/shared/site-header.tsx +48 -0
  230. package/template/apps/web/src/features/shared/theme-toggle.tsx +24 -0
  231. package/template/apps/web/src/i18n/navigation.ts +5 -0
  232. package/template/apps/web/src/i18n/request.ts +21 -0
  233. package/template/apps/web/src/i18n/routing.ts +6 -0
  234. package/template/apps/web/src/lib/auth-client.ts +19 -0
  235. package/template/apps/web/src/lib/auth-server.ts +46 -0
  236. package/template/apps/web/src/lib/auth.ts +8 -0
  237. package/template/apps/web/src/lib/codegen.ts +30 -0
  238. package/template/apps/web/src/lib/graphql-client.ts +29 -0
  239. package/template/apps/web/src/lib/graphql-server.ts +28 -0
  240. package/template/apps/web/src/lib/graphql-utils.test.ts +32 -0
  241. package/template/apps/web/src/lib/graphql-utils.ts +76 -0
  242. package/template/apps/web/src/lib/hooks/use-media-query.ts +21 -0
  243. package/template/apps/web/src/lib/metadata.ts +17 -0
  244. package/template/apps/web/src/lib/network-error.test.ts +30 -0
  245. package/template/apps/web/src/lib/network-error.ts +17 -0
  246. package/template/apps/web/src/lib/proxy-utils.ts +42 -0
  247. package/template/apps/web/src/lib/query-client.ts +29 -0
  248. package/template/apps/web/src/lib/utils.ts +5 -0
  249. package/template/apps/web/src/proxy.ts +57 -0
  250. package/template/apps/web/test-results/.last-run.json +4 -0
  251. package/template/apps/web/tests/auth.spec.ts +17 -0
  252. package/template/apps/web/tests/csp-theme-script.spec.ts +37 -0
  253. package/template/apps/web/tests/home.spec.ts +25 -0
  254. package/template/apps/web/tsconfig.build.json +4 -0
  255. package/template/apps/web/tsconfig.json +28 -0
  256. package/template/apps/web/vitest.config.ts +40 -0
  257. package/template/apps/web/vitest.shims.d.ts +1 -0
  258. package/template/apps/worker/.env.example +4 -0
  259. package/template/apps/worker/AGENTS.md +11 -0
  260. package/template/apps/worker/Dockerfile +35 -0
  261. package/template/apps/worker/knip.config.ts +3 -0
  262. package/template/apps/worker/kustomize/base/application.properties +4 -0
  263. package/template/apps/worker/kustomize/base/deployment.yaml +62 -0
  264. package/template/apps/worker/kustomize/base/kustomization.yaml +16 -0
  265. package/template/apps/worker/kustomize/overlays/prod/kustomization.yaml +12 -0
  266. package/template/apps/worker/kustomize/overlays/staging/application.properties +1 -0
  267. package/template/apps/worker/kustomize/overlays/staging/kustomization.yaml +18 -0
  268. package/template/apps/worker/oxfmt.config.ts +8 -0
  269. package/template/apps/worker/oxlint.config.ts +15 -0
  270. package/template/apps/worker/package.json +33 -0
  271. package/template/apps/worker/sonar-project.properties +8 -0
  272. package/template/apps/worker/src/env.ts +13 -0
  273. package/template/apps/worker/src/lib/graceful-shutdown.ts +27 -0
  274. package/template/apps/worker/src/lib/logger.ts +20 -0
  275. package/template/apps/worker/src/main.ts +17 -0
  276. package/template/apps/worker/src/processors/example-processor.ts +15 -0
  277. package/template/apps/worker/src/worker/redis.ts +19 -0
  278. package/template/apps/worker/src/worker/worker.ts +37 -0
  279. package/template/apps/worker/tests/example-processor.test.ts +13 -0
  280. package/template/apps/worker/tests/graceful-shutdown.test.ts +34 -0
  281. package/template/apps/worker/tsconfig.json +4 -0
  282. package/template/apps/worker/vitest.config.ts +13 -0
  283. package/template/bun.lock +3053 -0
  284. package/template/bunfig.toml +2 -0
  285. package/template/compose.yaml +53 -0
  286. package/template/docker-bake.hcl +86 -0
  287. package/template/package.json +47 -0
  288. package/template/packages/lint/knip.ts +40 -0
  289. package/template/packages/lint/oxfmt.ts +8 -0
  290. package/template/packages/lint/oxlint.ts +136 -0
  291. package/template/packages/lint/package.json +13 -0
  292. package/template/packages/queues/oxfmt.config.ts +8 -0
  293. package/template/packages/queues/oxlint.config.ts +7 -0
  294. package/template/packages/queues/package.json +15 -0
  295. package/template/packages/queues/src/example-job.ts +11 -0
  296. package/template/packages/queues/src/index.ts +3 -0
  297. package/template/packages/queues/src/queue-names.ts +2 -0
  298. package/template/packages/queues/src/redis-connection.ts +27 -0
  299. package/template/packages/queues/tsconfig.json +4 -0
  300. package/template/packages/tsconfig/base.json +23 -0
  301. package/template/packages/tsconfig/node.json +11 -0
  302. package/template/packages/tsconfig/package.json +4 -0
  303. package/template/turbo.json +50 -0
@@ -0,0 +1,27 @@
1
+ import type { UserSession as LibUserSession } from "@thallesp/nestjs-better-auth";
2
+ import type { FastifyRequest } from "fastify";
3
+ import type { UserRole } from "#src/generated/prisma/client.js";
4
+
5
+ export type RequestWithSession = FastifyRequest & {
6
+ session?: null | UserSession;
7
+ user?: null | UserSession["user"];
8
+ };
9
+
10
+ /**
11
+ * The authenticated user attached to the session: the Better Auth identity
12
+ * enriched with the app's {@link SessionProfile} fields.
13
+ */
14
+ export type SessionUser = LibUserSession["user"] & SessionProfile;
15
+
16
+ /** Session shape produced by Better Auth and enriched with the app's fields. */
17
+ export type UserSession = Omit<LibUserSession, "user"> & {
18
+ user: SessionUser;
19
+ };
20
+
21
+ /**
22
+ * The app's fields, merged onto the Better Auth user by the `customSession`
23
+ * plugin. Mirrors `USER_SESSION_SELECT` in `auth.ts`; keep them in sync.
24
+ */
25
+ type SessionProfile = {
26
+ role: UserRole[];
27
+ };
@@ -0,0 +1,12 @@
1
+ import { registerEnumType } from "@nestjs/graphql";
2
+ import { UserRole } from "#src/generated/prisma/client.js";
3
+
4
+ /**
5
+ * Registers Prisma enums with the GraphQL type system.
6
+ *
7
+ * Must be called before GraphQL schema generation so every Prisma enum a DTO
8
+ * exposes is available as a GraphQL enum type.
9
+ */
10
+ export function registerGraphqlEnums() {
11
+ registerEnumType(UserRole, { name: "UserRole" });
12
+ }
@@ -0,0 +1,24 @@
1
+ import type { FastifyCorsOptions } from "@fastify/cors";
2
+ import helmet from "@fastify/helmet";
3
+ import { ConfigService } from "@nestjs/config";
4
+ import type { NestFastifyApplication } from "@nestjs/platform-fastify";
5
+ import type { AppConfig } from "#src/config/app.config.js";
6
+
7
+ /**
8
+ * Configures security middleware for the application.
9
+ *
10
+ * @param app - The NestJS application instance
11
+ */
12
+ export async function addSecurity(app: NestFastifyApplication) {
13
+ const cfg = app.get(ConfigService<AppConfig, true>);
14
+ const securityConfig = cfg.get("security", { infer: true });
15
+
16
+ const corsOptions: FastifyCorsOptions = {
17
+ methods: securityConfig.allowedMethods,
18
+ origin: securityConfig.allowedOrigins,
19
+ ...securityConfig,
20
+ };
21
+
22
+ app.enableCors(corsOptions);
23
+ await app.register(helmet);
24
+ }
@@ -0,0 +1,28 @@
1
+ import { ConfigService } from "@nestjs/config";
2
+ import type { NestFastifyApplication } from "@nestjs/platform-fastify";
3
+ import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
4
+ import type { AppConfig } from "#src/config/app.config.js";
5
+
6
+ /**
7
+ * Configures Swagger/OpenAPI documentation for the application.
8
+ *
9
+ * @param app - The NestJS application instance
10
+ */
11
+ export function addSwagger(app: NestFastifyApplication): void {
12
+ const cfg = app.get(ConfigService<AppConfig, true>);
13
+ const appCfg = cfg.get("app", { infer: true });
14
+ const swaggerCfg = cfg.get("swagger", { infer: true });
15
+
16
+ const builder = new DocumentBuilder()
17
+ .setTitle(appCfg.title)
18
+ .setDescription(appCfg.description)
19
+ .setVersion(appCfg.version);
20
+
21
+ for (const tag of swaggerCfg.tags) {
22
+ builder.addTag(tag);
23
+ }
24
+ const config = builder.build();
25
+
26
+ const documentFactory = () => SwaggerModule.createDocument(app, config);
27
+ SwaggerModule.setup(swaggerCfg.path, app, documentFactory);
28
+ }
@@ -0,0 +1,22 @@
1
+ import { loadConfig } from "#src/config/app.config.js";
2
+
3
+ describe("loadConfig", () => {
4
+ it("merges app.yaml defaults with the validated environment", () => {
5
+ const config = loadConfig();
6
+
7
+ expect(config.app.title).toBeTruthy();
8
+ expect(config.db.url).toBe(process.env.DATABASE_URL);
9
+ expect(config.security.allowedMethods).toContain("GET");
10
+ });
11
+
12
+ it("fails fast when a required variable is missing", () => {
13
+ const original = process.env.DATABASE_URL;
14
+ delete process.env.DATABASE_URL;
15
+
16
+ try {
17
+ expect(() => loadConfig()).toThrow(/DATABASE_URL/);
18
+ } finally {
19
+ process.env.DATABASE_URL = original;
20
+ }
21
+ });
22
+ });
@@ -0,0 +1,122 @@
1
+ import { readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { load } from "js-yaml";
4
+ import { z } from "zod";
5
+
6
+ const YamlSchema = z.object({
7
+ app: z.object({
8
+ description: z.string().min(1),
9
+ title: z.string().min(1),
10
+ version: z.string().default("0.1.0"),
11
+ }),
12
+ // @if auth
13
+ mail: z.object({
14
+ from: z.string().min(1).default("Nexst <no-reply@example.com>"),
15
+ smtp: z
16
+ .object({
17
+ host: z.string().min(1).default("localhost"),
18
+ port: z.coerce.number().int().min(1).max(65_535).default(1025),
19
+ })
20
+ .prefault({}),
21
+ }),
22
+ // @endif
23
+ // @if r2
24
+ r2: z.object({
25
+ maxAttempts: z.coerce.number().int().positive().default(3),
26
+ presignExpiresInSeconds: z.coerce.number().int().positive().default(3600),
27
+ region: z.string().default("auto"),
28
+ }),
29
+ // @endif
30
+ security: z.object({
31
+ allowedHeaders: z.array(z.string()).default([]),
32
+ allowedMethods: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]).array(),
33
+ allowedOrigins: z.array(z.url()).default([]),
34
+ credentials: z.coerce.boolean().default(true),
35
+ }),
36
+ swagger: z.object({
37
+ path: z.string().default("api"),
38
+ tags: z.array(z.string()).default([]),
39
+ }),
40
+ });
41
+
42
+ const EnvironmentSchema = z.object({
43
+ // @if auth
44
+ BETTER_AUTH_SECRET: z
45
+ .string()
46
+ .min(32, "BETTER_AUTH_SECRET must be at least 32 characters (Better Auth recommendation)"),
47
+ BETTER_AUTH_URL: z.url("BETTER_AUTH_URL is required"),
48
+ // @endif
49
+ DATABASE_URL: z.url("DATABASE_URL is required"),
50
+ // @if auth
51
+ GOOGLE_CLIENT_ID: z.string().min(1).optional(),
52
+ GOOGLE_CLIENT_SECRET: z.string().min(1).optional(),
53
+ MAIL_SMTP_HOST: z.string().min(1).optional(),
54
+ // @endif
55
+ // @if r2
56
+ R2_ACCESS_KEY_ID: z.string().min(1, "R2_ACCESS_KEY_ID is required"),
57
+ R2_BUCKET: z.string().min(1, "R2_BUCKET is required"),
58
+ R2_ENDPOINT: z.url("R2_ENDPOINT is required"),
59
+ R2_PUBLIC_URL: z.url().optional(),
60
+ R2_SECRET_ACCESS_KEY: z.string().min(1, "R2_SECRET_ACCESS_KEY is required"),
61
+ // @endif
62
+ // @if worker
63
+ REDIS_HOST: z.string().default("redis"),
64
+ REDIS_PORT: z.coerce.number().min(1).max(65_535).default(6379),
65
+ REDIS_RETRY_LIMIT: z.coerce.number().min(1).default(5),
66
+ // @endif
67
+ // @if auth
68
+ RESEND_API_KEY: z.string().min(1).optional(),
69
+ // @endif
70
+ });
71
+
72
+ const configFile = path.resolve(import.meta.dirname, "resources", "app.yaml");
73
+
74
+ export type AppConfig = ReturnType<typeof loadConfig>;
75
+
76
+ /**
77
+ * Loads the static defaults from `resources/app.yaml` and the secrets and
78
+ * per-environment values from `process.env`, validating both. Fails fast on
79
+ * startup instead of at the first request that needs a missing value.
80
+ */
81
+ export function loadConfig() {
82
+ // eslint-disable-next-line security/detect-non-literal-fs-filename
83
+ const yaml = YamlSchema.parse(load(readFileSync(configFile, "utf8")));
84
+ const env = EnvironmentSchema.parse(process.env);
85
+
86
+ return {
87
+ ...yaml,
88
+ // @if auth
89
+ auth: {
90
+ baseUrl: env.BETTER_AUTH_URL,
91
+ secret: env.BETTER_AUTH_SECRET,
92
+ },
93
+ // @endif
94
+ db: {
95
+ url: env.DATABASE_URL,
96
+ },
97
+ // @if auth
98
+ mail: {
99
+ from: yaml.mail.from,
100
+ resendApiKey: env.RESEND_API_KEY,
101
+ smtp: { ...yaml.mail.smtp, host: env.MAIL_SMTP_HOST ?? yaml.mail.smtp.host },
102
+ },
103
+ // @endif
104
+ // @if r2
105
+ r2: {
106
+ ...yaml.r2,
107
+ accessKeyId: env.R2_ACCESS_KEY_ID,
108
+ bucket: env.R2_BUCKET,
109
+ endpoint: env.R2_ENDPOINT,
110
+ publicUrl: env.R2_PUBLIC_URL,
111
+ secretAccessKey: env.R2_SECRET_ACCESS_KEY,
112
+ },
113
+ // @endif
114
+ // @if worker
115
+ redis: {
116
+ host: env.REDIS_HOST,
117
+ port: env.REDIS_PORT,
118
+ retryLimit: env.REDIS_RETRY_LIMIT,
119
+ },
120
+ // @endif
121
+ };
122
+ }
@@ -0,0 +1,103 @@
1
+ import { GraphQLError, Kind } from "graphql";
2
+ import type {
3
+ ASTVisitor,
4
+ DefinitionNode,
5
+ FragmentDefinitionNode,
6
+ OperationDefinitionNode,
7
+ SelectionNode,
8
+ ValidationContext,
9
+ ValidationRule,
10
+ } from "graphql";
11
+
12
+ /**
13
+ * Ported from `graphql-depth-limit` (github.com/stems/graphql-depth-limit):
14
+ * that package's `require("graphql")` can't be satisfied under Bun, whose
15
+ * `graphql` package.json resolves to an ESM build Bun refuses to require()
16
+ * synchronously. Node never hit this because it has no "bun" export
17
+ * condition active and falls through to the CJS build instead.
18
+ */
19
+ export function depthLimit(maxDepth: number): ValidationRule {
20
+ return (context: ValidationContext): ASTVisitor => {
21
+ const { definitions } = context.getDocument();
22
+ const fragments = getFragments(definitions);
23
+ const operations = getOperations(definitions);
24
+
25
+ for (const [name, operation] of operations) {
26
+ determineDepth(operation, fragments, 0, maxDepth, context, name);
27
+ }
28
+
29
+ // The check above already ran eagerly against the whole document, so
30
+ // there's nothing left for graphql-js's AST walk to visit.
31
+ return {};
32
+ };
33
+ }
34
+
35
+ function determineDepth(
36
+ node: FragmentDefinitionNode | OperationDefinitionNode | SelectionNode,
37
+ fragments: Map<string, FragmentDefinitionNode>,
38
+ depthSoFar: number,
39
+ maxDepth: number,
40
+ context: ValidationContext,
41
+ operationName: string,
42
+ ): number {
43
+ if (depthSoFar > maxDepth) {
44
+ context.reportError(
45
+ new GraphQLError(`'${operationName}' exceeds maximum operation depth of ${maxDepth}`, {
46
+ nodes: [node],
47
+ }),
48
+ );
49
+ return 0;
50
+ }
51
+
52
+ switch (node.kind) {
53
+ case Kind.FIELD: {
54
+ if (node.name.value.startsWith("__") || !node.selectionSet) {
55
+ return 0;
56
+ }
57
+ return (
58
+ 1 +
59
+ Math.max(
60
+ ...node.selectionSet.selections.map((selection) =>
61
+ determineDepth(selection, fragments, depthSoFar + 1, maxDepth, context, operationName),
62
+ ),
63
+ )
64
+ );
65
+ }
66
+ case Kind.FRAGMENT_DEFINITION:
67
+ case Kind.INLINE_FRAGMENT:
68
+ case Kind.OPERATION_DEFINITION:
69
+ return Math.max(
70
+ ...node.selectionSet.selections.map((selection) =>
71
+ determineDepth(selection, fragments, depthSoFar, maxDepth, context, operationName),
72
+ ),
73
+ );
74
+ case Kind.FRAGMENT_SPREAD: {
75
+ const fragment = fragments.get(node.name.value);
76
+ return fragment
77
+ ? determineDepth(fragment, fragments, depthSoFar, maxDepth, context, operationName)
78
+ : 0;
79
+ }
80
+ default:
81
+ throw new Error("depth limit cannot handle this node kind");
82
+ }
83
+ }
84
+
85
+ function getFragments(definitions: readonly DefinitionNode[]) {
86
+ const fragments = new Map<string, FragmentDefinitionNode>();
87
+ for (const definition of definitions) {
88
+ if (definition.kind === Kind.FRAGMENT_DEFINITION) {
89
+ fragments.set(definition.name.value, definition);
90
+ }
91
+ }
92
+ return fragments;
93
+ }
94
+
95
+ function getOperations(definitions: readonly DefinitionNode[]) {
96
+ const operations = new Map<string, OperationDefinitionNode>();
97
+ for (const definition of definitions) {
98
+ if (definition.kind === Kind.OPERATION_DEFINITION) {
99
+ operations.set(definition.name?.value ?? "", definition);
100
+ }
101
+ }
102
+ return operations;
103
+ }
@@ -0,0 +1,77 @@
1
+ import { InMemoryLRUCache } from "@apollo/utils.keyvaluecache";
2
+ import { ApolloDriver } from "@nestjs/apollo";
3
+ import type { ApolloDriverConfig } from "@nestjs/apollo";
4
+ import { Logger } from "@nestjs/common";
5
+ import type { GraphQLFormattedError } from "graphql";
6
+ import { depthLimit } from "#src/config/graphql-depth-limit.js";
7
+ import type { AppExceptionResponse } from "#src/errors/app.exception.js";
8
+ import { ErrorCode } from "#src/errors/error-code.enum.js";
9
+
10
+ const logger = new Logger("GraphQL");
11
+
12
+ /**
13
+ * Reads back the structured payload `@nestjs/apollo` already attached.
14
+ *
15
+ * `ApolloBaseDriver.createTransformHttpErrorFn` unwraps any resolver-thrown
16
+ * `HttpException` and republishes its `getResponse()` body under
17
+ * `extensions.originalError` before this `formatError` runs — since
18
+ * `AppException#getResponse()` already *is* `{ code, params, ... }`, this is
19
+ * the single source of truth for both `message` and `extensions` below.
20
+ *
21
+ * @param formattedError - The error Apollo is about to send to the client
22
+ * @returns The `AppException` payload, or `undefined` for anything else
23
+ */
24
+ function readAppExceptionPayload(
25
+ formattedError: GraphQLFormattedError,
26
+ ): AppExceptionResponse | undefined {
27
+ const original = formattedError.extensions?.originalError;
28
+ return original && typeof original === "object" && "code" in original
29
+ ? (original as AppExceptionResponse)
30
+ : undefined;
31
+ }
32
+
33
+ export const graphqlConfig: ApolloDriverConfig = {
34
+ autoSchemaFile: true,
35
+ csrfPrevention: true,
36
+ debug: process.env.NODE_ENV === "development" || process.env.NODE_ENV === "test",
37
+ driver: ApolloDriver,
38
+ formatError: (formattedError: GraphQLFormattedError, error: unknown): GraphQLFormattedError => {
39
+ const appPayload = readAppExceptionPayload(formattedError);
40
+ if (appPayload) {
41
+ return {
42
+ extensions: {
43
+ code: appPayload.code,
44
+ ...(appPayload.params ? { params: appPayload.params } : {}),
45
+ statusCode: appPayload.statusCode,
46
+ },
47
+ locations: formattedError.locations,
48
+ message: appPayload.code,
49
+ path: formattedError.path,
50
+ };
51
+ }
52
+
53
+ const originalError = error instanceof Error ? error : null;
54
+ const message = originalError?.message ?? "";
55
+ const isPrismaError = message.includes("Invalid `prisma") || message.includes("Argument `");
56
+
57
+ if (isPrismaError) {
58
+ logger.error("Prisma error:", originalError?.message);
59
+
60
+ return {
61
+ extensions: { code: ErrorCode.INTERNAL_SERVER_ERROR },
62
+ message: ErrorCode.INTERNAL_SERVER_ERROR,
63
+ };
64
+ }
65
+
66
+ return formattedError;
67
+ },
68
+ graphiql: process.env.NODE_ENV === "development",
69
+ inheritResolversFromInterfaces: true,
70
+ introspection: process.env.NODE_ENV !== "production",
71
+ persistedQueries: {
72
+ cache: new InMemoryLRUCache(),
73
+ ttl: 300,
74
+ },
75
+ sortSchema: true,
76
+ validationRules: [depthLimit(7)],
77
+ };
@@ -0,0 +1,29 @@
1
+ app:
2
+ title: Nexst API
3
+ description: The Nexst backend
4
+ version: "0.1.0"
5
+
6
+ swagger:
7
+ tags: [health]
8
+ path: api
9
+ # @if r2
10
+
11
+ r2:
12
+ maxAttempts: 5
13
+ presignExpiresInSeconds: 3600
14
+ region: auto
15
+ # @endif
16
+ # @if auth
17
+
18
+ mail:
19
+ from: Nexst <no-reply@example.com>
20
+ smtp:
21
+ host: localhost
22
+ port: 1025
23
+ # @endif
24
+
25
+ security:
26
+ allowedHeaders: [Content-Type, Authorization, Apollo-Require-Preflight]
27
+ allowedMethods: [GET, POST, PUT, PATCH, DELETE, OPTIONS]
28
+ allowedOrigins: [http://localhost:3000, https://example.com]
29
+ credentials: true
@@ -0,0 +1,27 @@
1
+ import { HttpStatus, ParseUUIDPipe } from "@nestjs/common";
2
+ import type { ParseUUIDPipeOptions } from "@nestjs/common";
3
+ import { AppException } from "#src/errors/app.exception.js";
4
+ import { ErrorCode } from "#src/errors/error-code.enum.js";
5
+
6
+ /**
7
+ * Drop-in replacement for `ParseUUIDPipe` that fails with an
8
+ * {@link AppException} instead of the built-in pipe's hardcoded
9
+ * "Validation failed (uuid is expected)" text.
10
+ *
11
+ * `ParseUUIDPipe` is instantiated directly at resolver/controller
12
+ * parameters across the API and is not covered by the global
13
+ * `ValidationPipe`'s `exceptionFactory` — this wrapper closes that gap with
14
+ * the same `VALIDATION_FAILED` code and violation shape.
15
+ *
16
+ * @param options - The same options `ParseUUIDPipe` accepts
17
+ * @returns A `ParseUUIDPipe` configured to throw codes-only on failure
18
+ */
19
+ export function AppParseUUIDPipe(options?: ParseUUIDPipeOptions): ParseUUIDPipe {
20
+ return new ParseUUIDPipe({
21
+ ...options,
22
+ exceptionFactory: () =>
23
+ new AppException(ErrorCode.VALIDATION_FAILED, HttpStatus.BAD_REQUEST, {
24
+ violations: [{ constraints: ["isUuid"], field: "id" }],
25
+ }),
26
+ });
27
+ }
@@ -0,0 +1,47 @@
1
+ import { HttpException } from "@nestjs/common";
2
+ import type { HttpStatus } from "@nestjs/common";
3
+ import type { ErrorCode } from "#src/errors/error-code.enum.js";
4
+
5
+ /** The exact shape `AppException#getResponse()` returns. */
6
+ export interface AppExceptionResponse {
7
+ code: ErrorCode;
8
+ message: ErrorCode;
9
+ params?: ErrorParams;
10
+ statusCode: HttpStatus;
11
+ }
12
+
13
+ /**
14
+ * Structured, JSON-serializable interpolation data for a code. Left as
15
+ * `unknown` values rather than restricted to primitives, since some codes
16
+ * (e.g. `VALIDATION_FAILED`) carry a list of `{ field, constraints }`
17
+ * objects rather than a flat key-value pair.
18
+ */
19
+ export type ErrorParams = Record<string, unknown>;
20
+
21
+ /**
22
+ * The one exception type application code throws for any expected,
23
+ * user-facing failure — a missing record, a name conflict, an unauthorized
24
+ * action. It carries a machine-readable `code` and optional structured
25
+ * `params` for interpolation instead of an English sentence; the frontend
26
+ * owns turning `code` + `params` into copy.
27
+ *
28
+ * The HTTP status is still chosen by the call site, exactly as with
29
+ * `NotFoundException`/`ConflictException`/etc. — this class only removes
30
+ * the free-text message, it does not change status semantics.
31
+ *
32
+ * `message` is set to the code string itself, not left for `HttpException`
33
+ * to derive a title from the class name: every consumer that reads
34
+ * `.message` (logs, `@nestjs/apollo`'s resolver-error unwrapping) should see
35
+ * the code, never prose.
36
+ */
37
+ export class AppException extends HttpException {
38
+ readonly code: ErrorCode;
39
+ readonly params?: ErrorParams;
40
+
41
+ constructor(code: ErrorCode, status: HttpStatus, params?: ErrorParams) {
42
+ const response: AppExceptionResponse = { code, message: code, params, statusCode: status };
43
+ super(response, status);
44
+ this.code = code;
45
+ this.params = params;
46
+ }
47
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Every machine-readable error code the API can return, over REST or GraphQL.
3
+ *
4
+ * Flat, no per-module namespacing: a duplicate failure thrown from two
5
+ * modules (e.g. "user not found") collapses onto one member here. Every
6
+ * call site throws {@link AppException} with one of these codes instead of
7
+ * a free-text message; grows as new failure cases are added.
8
+ */
9
+ export enum ErrorCode {
10
+ /** Generic fallback for a 400 that carries no more specific code. */
11
+ BAD_REQUEST = "BAD_REQUEST",
12
+
13
+ /** Generic fallback for a 409 that carries no more specific code. */
14
+ CONFLICT = "CONFLICT",
15
+
16
+ // @if auth
17
+ /** The user's email address is not verified. */
18
+ EMAIL_NOT_VERIFIED = "EMAIL_NOT_VERIFIED",
19
+ // @endif
20
+
21
+ /** Generic fallback for a 403 that carries no more specific code. */
22
+ FORBIDDEN = "FORBIDDEN",
23
+
24
+ // @if auth
25
+ /** The route requires a role the user does not have. */
26
+ INSUFFICIENT_ROLE = "INSUFFICIENT_ROLE",
27
+ // @endif
28
+
29
+ /** Generic fallback for a 500 that carries no more specific code. */
30
+ INTERNAL_SERVER_ERROR = "INTERNAL_SERVER_ERROR",
31
+
32
+ /** Generic fallback for a 404 that carries no more specific code. */
33
+ NOT_FOUND = "NOT_FOUND",
34
+
35
+ // @if auth
36
+ /** The request's underlying HTTP context could not be resolved. */
37
+ REQUEST_CONTEXT_MISSING = "REQUEST_CONTEXT_MISSING",
38
+ // @endif
39
+
40
+ /** Generic fallback for a 503 that carries no more specific code. */
41
+ SERVICE_UNAVAILABLE = "SERVICE_UNAVAILABLE",
42
+
43
+ /** Generic fallback for a 429: the caller hit a rate limit and should retry. */
44
+ TOO_MANY_REQUESTS = "TOO_MANY_REQUESTS",
45
+
46
+ /** Generic fallback for a 401 that carries no more specific code. */
47
+ UNAUTHORIZED = "UNAUTHORIZED",
48
+
49
+ /** Generic fallback for a 422 that carries no more specific code. */
50
+ UNPROCESSABLE_ENTITY = "UNPROCESSABLE_ENTITY",
51
+
52
+ /** One or more DTO fields failed class-validator validation. */
53
+ VALIDATION_FAILED = "VALIDATION_FAILED",
54
+ }
@@ -0,0 +1,27 @@
1
+ import { HttpStatus } from "@nestjs/common";
2
+ import { ErrorCode } from "#src/errors/error-code.enum.js";
3
+
4
+ const STATUS_FALLBACK_CODE: Partial<Record<HttpStatus, ErrorCode>> = {
5
+ [HttpStatus.BAD_REQUEST]: ErrorCode.BAD_REQUEST,
6
+ [HttpStatus.CONFLICT]: ErrorCode.CONFLICT,
7
+ [HttpStatus.FORBIDDEN]: ErrorCode.FORBIDDEN,
8
+ [HttpStatus.NOT_FOUND]: ErrorCode.NOT_FOUND,
9
+ [HttpStatus.SERVICE_UNAVAILABLE]: ErrorCode.SERVICE_UNAVAILABLE,
10
+ [HttpStatus.TOO_MANY_REQUESTS]: ErrorCode.TOO_MANY_REQUESTS,
11
+ [HttpStatus.UNAUTHORIZED]: ErrorCode.UNAUTHORIZED,
12
+ [HttpStatus.UNPROCESSABLE_ENTITY]: ErrorCode.UNPROCESSABLE_ENTITY,
13
+ };
14
+
15
+ /**
16
+ * Maps an HTTP status to a generic {@link ErrorCode} fallback.
17
+ *
18
+ * Used when an exception reaching a filter is not an {@link AppException} —
19
+ * a third-party exception, or a call site not yet migrated — so the response
20
+ * never falls back to free text even outside this rewrite's own call sites.
21
+ *
22
+ * @param status - The HTTP status the response will carry
23
+ * @returns The generic code for that status, or `INTERNAL_SERVER_ERROR`
24
+ */
25
+ export function fallbackCodeForStatus(status: HttpStatus): ErrorCode {
26
+ return STATUS_FALLBACK_CODE[status] ?? ErrorCode.INTERNAL_SERVER_ERROR;
27
+ }
@@ -0,0 +1,43 @@
1
+ import { HttpStatus } from "@nestjs/common";
2
+ import type { ValidationError } from "@nestjs/common";
3
+ import { AppException } from "#src/errors/app.exception.js";
4
+ import { ErrorCode } from "#src/errors/error-code.enum.js";
5
+
6
+ interface FieldViolation {
7
+ constraints: string[];
8
+ field: string;
9
+ }
10
+
11
+ /**
12
+ * `ValidationPipe`'s `exceptionFactory`: turns `class-validator`'s
13
+ * `ValidationError[]` into a single {@link AppException}.
14
+ *
15
+ * `ValidationError.constraints` is already keyed by the constraint's own
16
+ * machine-readable name (`isEmail`, `minLength`, …) — only its value is the
17
+ * English default sentence, which this factory discards.
18
+ *
19
+ * @param errors - The validation errors `class-validator` produced
20
+ * @returns An `AppException` carrying every violated field and rule
21
+ */
22
+ export function validationExceptionFactory(errors: ValidationError[]): AppException {
23
+ return new AppException(ErrorCode.VALIDATION_FAILED, HttpStatus.BAD_REQUEST, {
24
+ violations: flatten(errors),
25
+ });
26
+ }
27
+
28
+ /**
29
+ * Flattens `class-validator`'s nested `ValidationError[]` (used by
30
+ * `@ValidateNested()` DTOs) into dotted field paths.
31
+ *
32
+ * @param errors - The validation errors to flatten
33
+ * @param prefix - The dotted field path accumulated so far
34
+ * @returns One violation per invalid field, however deeply nested
35
+ */
36
+ function flatten(errors: ValidationError[], prefix = ""): FieldViolation[] {
37
+ return errors.flatMap((error) => {
38
+ const field = prefix ? `${prefix}.${error.property}` : error.property;
39
+ const own = error.constraints ? [{ constraints: Object.keys(error.constraints), field }] : [];
40
+ const nested = error.children?.length ? flatten(error.children, field) : [];
41
+ return [...own, ...nested];
42
+ });
43
+ }