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,138 @@
1
+ import { execFile } from "node:child_process";
2
+ import { fileURLToPath } from "node:url";
3
+ import { promisify } from "node:util";
4
+ import { Injectable } from "@nestjs/common";
5
+ import type { OnModuleDestroy, OnModuleInit } from "@nestjs/common";
6
+ import { ConfigService } from "@nestjs/config";
7
+ import { PrismaPg } from "@prisma/adapter-pg";
8
+ import type { AppConfig } from "#src/config/app.config.js";
9
+ import { PrismaClient } from "#src/generated/prisma/client.js";
10
+ import type { Prisma } from "#src/generated/prisma/client.js";
11
+ import { ContextLogger } from "#src/logging/context-logger.js";
12
+ import { handleError } from "#src/utils/error-handler.util.js";
13
+
14
+ const execFileAsync = promisify(execFile);
15
+
16
+ /**
17
+ * Prisma service for database access and lifecycle management.
18
+ *
19
+ * - Extends PrismaClient with NestJS lifecycle hooks
20
+ * - Configures query logging for development
21
+ * - Provides health check functionality
22
+ * - Handles connection management
23
+ */
24
+ @Injectable()
25
+ export class PrismaService extends PrismaClient implements OnModuleDestroy, OnModuleInit {
26
+ constructor(
27
+ cfg: ConfigService<AppConfig, true>,
28
+ private readonly logger: ContextLogger,
29
+ ) {
30
+ const adapter = new PrismaPg({
31
+ connectionString: cfg.get("db.url", { infer: true }),
32
+ });
33
+
34
+ super({
35
+ adapter,
36
+ errorFormat: "pretty",
37
+ log: [
38
+ { emit: "event", level: "query" },
39
+ { emit: "event", level: "info" },
40
+ { emit: "event", level: "warn" },
41
+ { emit: "event", level: "error" },
42
+ ],
43
+ });
44
+
45
+ this.$on("query" as never, (e: Prisma.QueryEvent) => {
46
+ if (process.env.NODE_ENV !== "production") {
47
+ this.logger.debug({ duration: e.duration, query: e.query }, "Query executed");
48
+ }
49
+ });
50
+ this.$on("info" as never, (e: Prisma.LogEvent) => {
51
+ this.logger.info(e.message);
52
+ });
53
+ this.$on("warn" as never, (e: Prisma.LogEvent) => {
54
+ this.logger.warn(e.message);
55
+ });
56
+ this.$on("error" as never, (e: Prisma.LogEvent) => {
57
+ this.logger.error(e.message);
58
+ });
59
+ }
60
+
61
+ /**
62
+ * Checks database connectivity.
63
+ *
64
+ * @returns True if database is reachable, false otherwise
65
+ */
66
+ async isHealthy(): Promise<boolean> {
67
+ try {
68
+ await this.$queryRaw`SELECT 1`;
69
+ return true;
70
+ } catch (error: unknown) {
71
+ this.logger.error({ error: handleError(error) }, "Database health check failed");
72
+ return false;
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Disconnects from database on module destruction.
78
+ *
79
+ * @returns A promise that resolves when disconnection is complete
80
+ */
81
+ async onModuleDestroy(): Promise<void> {
82
+ try {
83
+ await this.$disconnect();
84
+ this.logger.info("Database connection closed");
85
+ } catch (error: unknown) {
86
+ this.logger.error({ error: handleError(error) }, "Error during database disconnect");
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Connects to database on module initialization.
92
+ *
93
+ * @returns A promise that resolves when connection is established
94
+ * @throws {Error} When database connection fails
95
+ */
96
+ async onModuleInit(): Promise<void> {
97
+ try {
98
+ await this.$connect();
99
+ this.logger.info("Successfully connected to database");
100
+ } catch (error: unknown) {
101
+ this.logger.error({ error: handleError(error) }, "Failed to connect to database");
102
+ throw error;
103
+ }
104
+
105
+ await this.applyMigrations();
106
+ }
107
+
108
+ /**
109
+ * Applies pending database migrations on startup.
110
+ *
111
+ * Runs `prisma migrate deploy`, which is idempotent: it creates the schema
112
+ * from scratch when the database is empty, applies any pending migrations,
113
+ * and is a no-op when the schema is already up to date.
114
+ *
115
+ * Skipped when `PRISMA_AUTO_MIGRATE=false` (e.g. when migrations are run as a
116
+ * separate deploy step).
117
+ *
118
+ * @throws {Error} When migrations fail to apply
119
+ */
120
+ private async applyMigrations(): Promise<void> {
121
+ if (process.env.PRISMA_AUTO_MIGRATE === "false") {
122
+ this.logger.info("Auto-migration disabled (PRISMA_AUTO_MIGRATE=false), skipping");
123
+ return;
124
+ }
125
+
126
+ try {
127
+ const prismaCli = fileURLToPath(import.meta.resolve("prisma/build/index.js"));
128
+ const { stdout } = await execFileAsync(process.execPath, [prismaCli, "migrate", "deploy"], {
129
+ cwd: process.cwd(),
130
+ env: process.env,
131
+ });
132
+ this.logger.info({ output: stdout.trim() }, "Database migrations applied");
133
+ } catch (error: unknown) {
134
+ this.logger.error({ error: handleError(error) }, "Failed to apply database migrations");
135
+ throw error;
136
+ }
137
+ }
138
+ }
@@ -0,0 +1,11 @@
1
+ import { Field, InputType } from "@nestjs/graphql";
2
+ import { IsString, MaxLength, MinLength } from "class-validator";
3
+
4
+ @InputType()
5
+ export class EnqueueExampleInput {
6
+ @Field()
7
+ @IsString()
8
+ @MaxLength(500)
9
+ @MinLength(1)
10
+ message: string;
11
+ }
@@ -0,0 +1,14 @@
1
+ import { Args, ID, Mutation, Resolver } from "@nestjs/graphql";
2
+ import { EnqueueExampleInput } from "#src/queue/dto/enqueue-example.input.js";
3
+ import { ExampleQueueService } from "#src/queue/example-queue.service.js";
4
+
5
+ @Resolver()
6
+ export class ExampleQueueResolver {
7
+ constructor(private readonly exampleQueue: ExampleQueueService) {}
8
+
9
+ /** Hands a message to apps/worker and returns the BullMQ job id. */
10
+ @Mutation(() => ID)
11
+ enqueueExample(@Args("input") { message }: EnqueueExampleInput): Promise<string> {
12
+ return this.exampleQueue.enqueue(message);
13
+ }
14
+ }
@@ -0,0 +1,21 @@
1
+ import { vi } from "vitest";
2
+ import type { ContextLogger } from "#src/logging/context-logger.js";
3
+ import { ExampleQueueService } from "#src/queue/example-queue.service.js";
4
+
5
+ describe("exampleQueueService", () => {
6
+ it("adds a retrying job with the message and returns its id", async () => {
7
+ const add = vi.fn().mockResolvedValue({ id: "42" });
8
+ const service = new ExampleQueueService(
9
+ { add } as never,
10
+ { debug: vi.fn() } as unknown as ContextLogger,
11
+ );
12
+
13
+ await expect(service.enqueue("hello")).resolves.toBe("42");
14
+
15
+ expect(add).toHaveBeenCalledWith(
16
+ "example",
17
+ expect.objectContaining({ message: "hello" }),
18
+ expect.objectContaining({ attempts: 3 }),
19
+ );
20
+ });
21
+ });
@@ -0,0 +1,32 @@
1
+ import { InjectQueue } from "@nestjs/bullmq";
2
+ import { Injectable } from "@nestjs/common";
3
+ import { EXAMPLE_QUEUE } from "@nexst/queues";
4
+ import type { ExampleJob, ExampleJobResult } from "@nexst/queues";
5
+ import { Queue } from "bullmq";
6
+ import { ContextLogger } from "#src/logging/context-logger.js";
7
+
8
+ /**
9
+ * Enqueues jobs for apps/worker. Replace the example payload with real work.
10
+ */
11
+ @Injectable()
12
+ export class ExampleQueueService {
13
+ constructor(
14
+ @InjectQueue(EXAMPLE_QUEUE) private readonly queue: Queue<ExampleJob, ExampleJobResult>,
15
+ private readonly logger: ContextLogger,
16
+ ) {}
17
+
18
+ /**
19
+ * Adds one job and returns its id.
20
+ *
21
+ * @param message - Text the worker processes
22
+ */
23
+ async enqueue(message: string): Promise<string> {
24
+ const job = await this.queue.add(
25
+ "example",
26
+ { message, requestedAt: new Date().toISOString() },
27
+ { attempts: 3, backoff: { delay: 1000, type: "exponential" }, removeOnComplete: 100 },
28
+ );
29
+ this.logger.debug({ jobId: job.id }, "Example job enqueued");
30
+ return job.id ?? "";
31
+ }
32
+ }
@@ -0,0 +1,27 @@
1
+ import { BullModule } from "@nestjs/bullmq";
2
+ import { Module } from "@nestjs/common";
3
+ import { ConfigService } from "@nestjs/config";
4
+ import { createRedisConnectionOptions, EXAMPLE_QUEUE } from "@nexst/queues";
5
+ import type { AppConfig } from "#src/config/app.config.js";
6
+ import { ExampleQueueResolver } from "#src/queue/example-queue.resolver.js";
7
+ import { ExampleQueueService } from "#src/queue/example-queue.service.js";
8
+
9
+ /**
10
+ * BullMQ producer side: connects to Redis and registers the queues apps/worker
11
+ * consumes. Queue names and payload types live in `@nexst/queues` so both
12
+ * sides stay in sync.
13
+ */
14
+ @Module({
15
+ exports: [ExampleQueueService],
16
+ imports: [
17
+ BullModule.forRootAsync({
18
+ inject: [ConfigService],
19
+ useFactory: (cfg: ConfigService<AppConfig, true>) => ({
20
+ connection: createRedisConnectionOptions(cfg.get("redis", { infer: true })),
21
+ }),
22
+ }),
23
+ BullModule.registerQueue({ name: EXAMPLE_QUEUE }),
24
+ ],
25
+ providers: [ExampleQueueResolver, ExampleQueueService],
26
+ })
27
+ export class QueueModule {}
@@ -0,0 +1,12 @@
1
+ import { Global, Module } from "@nestjs/common";
2
+ import { R2Service } from "#src/r2/r2.service.js";
3
+
4
+ /**
5
+ * Global module sharing the Cloudflare R2 storage service.
6
+ */
7
+ @Global()
8
+ @Module({
9
+ exports: [R2Service],
10
+ providers: [R2Service],
11
+ })
12
+ export class R2Module {}
@@ -0,0 +1,117 @@
1
+ import {
2
+ DeleteObjectCommand,
3
+ DeleteObjectsCommand,
4
+ PutObjectCommand,
5
+ S3Client,
6
+ } from "@aws-sdk/client-s3";
7
+ import { ConfigService } from "@nestjs/config";
8
+ import type { TestingModule } from "@nestjs/testing";
9
+ import { Test } from "@nestjs/testing";
10
+ import { mockClient } from "aws-sdk-client-mock";
11
+ import { vi } from "vitest";
12
+ import { ContextLogger } from "#src/logging/context-logger.js";
13
+ import { R2Service } from "#src/r2/r2.service.js";
14
+
15
+ describe("r2Service", () => {
16
+ const s3Mock = mockClient(S3Client);
17
+ let service: R2Service;
18
+
19
+ const build = async (publicUrl?: string) => {
20
+ const r2 = {
21
+ accessKeyId: "test-key",
22
+ bucket: "test-bucket",
23
+ endpoint: "https://account.r2.cloudflarestorage.com",
24
+ maxAttempts: 3,
25
+ presignExpiresInSeconds: 600,
26
+ publicUrl,
27
+ region: "auto",
28
+ secretAccessKey: "test-secret",
29
+ };
30
+
31
+ const mod: TestingModule = await Test.createTestingModule({
32
+ providers: [
33
+ R2Service,
34
+ { provide: ConfigService, useValue: { get: vi.fn(() => r2) } },
35
+ { provide: ContextLogger, useValue: { debug: vi.fn(), error: vi.fn() } },
36
+ ],
37
+ }).compile();
38
+
39
+ service = mod.get(R2Service);
40
+ };
41
+
42
+ beforeEach(async () => {
43
+ s3Mock.reset();
44
+ await build();
45
+ });
46
+
47
+ it("uploads an object to the configured bucket", async () => {
48
+ s3Mock.on(PutObjectCommand).resolves({ ETag: "etag" });
49
+
50
+ const key = await service.putObject({ body: "hello", contentType: "text/plain", key: "a.txt" });
51
+
52
+ expect(key).toBe("a.txt");
53
+ expect(s3Mock.commandCalls(PutObjectCommand)[0].args[0].input).toMatchObject({
54
+ Bucket: "test-bucket",
55
+ ContentType: "text/plain",
56
+ Key: "a.txt",
57
+ });
58
+ });
59
+
60
+ it("rethrows upload failures", async () => {
61
+ s3Mock.on(PutObjectCommand).rejects(new Error("Upload failed"));
62
+
63
+ await expect(
64
+ service.putObject({ body: "x", contentType: "text/plain", key: "a.txt" }),
65
+ ).rejects.toThrow("Upload failed");
66
+ });
67
+
68
+ it("deletes a single object", async () => {
69
+ s3Mock.on(DeleteObjectCommand).resolves({});
70
+
71
+ await service.deleteObject("a.txt");
72
+
73
+ expect(s3Mock.commandCalls(DeleteObjectCommand)[0].args[0].input).toEqual({
74
+ Bucket: "test-bucket",
75
+ Key: "a.txt",
76
+ });
77
+ });
78
+
79
+ it("batches bulk deletes to 1000 keys per request", async () => {
80
+ s3Mock.on(DeleteObjectsCommand).resolves({});
81
+ const keys = Array.from({ length: 1001 }, (_, index) => `file-${index}`);
82
+
83
+ await service.deleteObjects(keys);
84
+
85
+ const calls = s3Mock.commandCalls(DeleteObjectsCommand);
86
+ expect(calls).toHaveLength(2);
87
+ expect(calls[1].args[0].input.Delete?.Objects).toEqual([{ Key: "file-1000" }]);
88
+ });
89
+
90
+ it("does not call R2 for an empty key list", async () => {
91
+ await service.deleteObjects([]);
92
+
93
+ expect(s3Mock.commandCalls(DeleteObjectsCommand)).toHaveLength(0);
94
+ });
95
+
96
+ it("signs a download URL with an attachment filename", async () => {
97
+ const url = await service.getPresignedDownloadUrl("docs/a.pdf", 'My "File".pdf');
98
+
99
+ expect(url).toContain("test-bucket");
100
+ expect(url).toContain("docs/a.pdf");
101
+ expect(decodeURIComponent(url)).toContain('filename="My File.pdf"');
102
+ });
103
+
104
+ it("signs an upload URL", async () => {
105
+ const url = await service.getPresignedUploadUrl("docs/b.pdf", "application/pdf");
106
+
107
+ expect(url).toContain("X-Amz-Signature");
108
+ });
109
+
110
+ it("builds public URLs only when a public base is configured", async () => {
111
+ expect(service.getPublicUrl("a.png")).toBeUndefined();
112
+
113
+ await build("https://cdn.example.com/");
114
+
115
+ expect(service.getPublicUrl("a.png")).toBe("https://cdn.example.com/a.png");
116
+ });
117
+ });
@@ -0,0 +1,131 @@
1
+ import type { Readable } from "node:stream";
2
+ import {
3
+ DeleteObjectCommand,
4
+ DeleteObjectsCommand,
5
+ GetObjectCommand,
6
+ PutObjectCommand,
7
+ S3Client,
8
+ } from "@aws-sdk/client-s3";
9
+ import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
10
+ import { Injectable } from "@nestjs/common";
11
+ import { ConfigService } from "@nestjs/config";
12
+ import type { AppConfig } from "#src/config/app.config.js";
13
+ import { ContextLogger } from "#src/logging/context-logger.js";
14
+ import { handleError } from "#src/utils/error-handler.util.js";
15
+
16
+ /** R2's `DeleteObjects` accepts at most this many keys per request. */
17
+ const DELETE_BATCH_SIZE = 1000;
18
+
19
+ export type PutObjectInput = {
20
+ body: Buffer | Readable | string | Uint8Array;
21
+ contentType: string;
22
+ key: string;
23
+ metadata?: Record<string, string>;
24
+ };
25
+
26
+ /**
27
+ * Service for Cloudflare R2 object storage (S3-compatible API).
28
+ *
29
+ * - Uploads and deletes objects in the configured bucket
30
+ * - Signs short-lived GET/PUT URLs so clients never see credentials
31
+ * - Builds public URLs when the bucket is exposed via `R2_PUBLIC_URL`
32
+ */
33
+ @Injectable()
34
+ export class R2Service {
35
+ private readonly bucket: string;
36
+ private readonly client: S3Client;
37
+ private readonly presignExpiresInSeconds: number;
38
+ private readonly publicUrl?: string;
39
+
40
+ constructor(
41
+ cfg: ConfigService<AppConfig, true>,
42
+ private readonly logger: ContextLogger,
43
+ ) {
44
+ const r2 = cfg.get("r2", { infer: true });
45
+
46
+ this.bucket = r2.bucket;
47
+ this.presignExpiresInSeconds = r2.presignExpiresInSeconds;
48
+ this.publicUrl = r2.publicUrl?.replace(/\/+$/, "");
49
+ this.client = new S3Client({
50
+ credentials: { accessKeyId: r2.accessKeyId, secretAccessKey: r2.secretAccessKey },
51
+ endpoint: r2.endpoint,
52
+ maxAttempts: r2.maxAttempts,
53
+ region: r2.region,
54
+ });
55
+ }
56
+
57
+ /** Deletes a single object; a missing key is not an error. */
58
+ async deleteObject(key: string): Promise<void> {
59
+ await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: key }));
60
+ }
61
+
62
+ /** Deletes many objects, batched to R2's per-request limit. */
63
+ async deleteObjects(keys: string[]): Promise<void> {
64
+ for (let start = 0; start < keys.length; start += DELETE_BATCH_SIZE) {
65
+ const batch = keys.slice(start, start + DELETE_BATCH_SIZE);
66
+ await this.client.send(
67
+ new DeleteObjectsCommand({
68
+ Bucket: this.bucket,
69
+ Delete: { Objects: batch.map((Key) => ({ Key })), Quiet: true },
70
+ }),
71
+ );
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Signs a URL the client can download the object from.
77
+ *
78
+ * @param key - The object key
79
+ * @param downloadName - When set, the browser saves the file under this name
80
+ */
81
+ getPresignedDownloadUrl(key: string, downloadName?: string): Promise<string> {
82
+ const command = new GetObjectCommand({
83
+ Bucket: this.bucket,
84
+ Key: key,
85
+ ...(downloadName
86
+ ? {
87
+ ResponseContentDisposition: `attachment; filename="${downloadName.replaceAll('"', "")}"`,
88
+ }
89
+ : {}),
90
+ });
91
+ return getSignedUrl(this.client, command, { expiresIn: this.presignExpiresInSeconds });
92
+ }
93
+
94
+ /** Signs a URL the client can upload one object to directly, bypassing the API. */
95
+ getPresignedUploadUrl(key: string, contentType: string): Promise<string> {
96
+ const command = new PutObjectCommand({
97
+ Bucket: this.bucket,
98
+ ContentType: contentType,
99
+ Key: key,
100
+ });
101
+ return getSignedUrl(this.client, command, { expiresIn: this.presignExpiresInSeconds });
102
+ }
103
+
104
+ /**
105
+ * The public URL of an object, for buckets exposed through a custom domain
106
+ * or r2.dev (`R2_PUBLIC_URL`). Undefined when the bucket is private.
107
+ */
108
+ getPublicUrl(key: string): string | undefined {
109
+ return this.publicUrl ? `${this.publicUrl}/${key}` : undefined;
110
+ }
111
+
112
+ /** Uploads an object and returns its key. */
113
+ async putObject({ body, contentType, key, metadata }: PutObjectInput): Promise<string> {
114
+ try {
115
+ await this.client.send(
116
+ new PutObjectCommand({
117
+ Body: body,
118
+ Bucket: this.bucket,
119
+ ContentType: contentType,
120
+ Key: key,
121
+ Metadata: metadata,
122
+ }),
123
+ );
124
+ this.logger.debug({ key }, "Object uploaded to R2");
125
+ return key;
126
+ } catch (error: unknown) {
127
+ this.logger.error({ error: handleError(error), key }, "R2 upload failed");
128
+ throw error;
129
+ }
130
+ }
131
+ }
@@ -0,0 +1,26 @@
1
+ import { Field, ID, ObjectType } from "@nestjs/graphql";
2
+ import { UserRole } from "#src/generated/prisma/client.js";
3
+
4
+ @ObjectType("User")
5
+ export class UserDto {
6
+ @Field()
7
+ createdAt: Date;
8
+
9
+ @Field()
10
+ email: string;
11
+
12
+ @Field()
13
+ emailVerified: boolean;
14
+
15
+ @Field(() => ID)
16
+ id: string;
17
+
18
+ @Field(() => String, { nullable: true })
19
+ image: null | string;
20
+
21
+ @Field(() => String, { nullable: true })
22
+ name: null | string;
23
+
24
+ @Field(() => [UserRole])
25
+ role: UserRole[];
26
+ }
@@ -0,0 +1,10 @@
1
+ import { Module } from "@nestjs/common";
2
+ import { UserResolver } from "#src/user/user.resolver.js";
3
+
4
+ /**
5
+ * Exposes the signed-in user over GraphQL.
6
+ */
7
+ @Module({
8
+ providers: [UserResolver],
9
+ })
10
+ export class UserModule {}
@@ -0,0 +1,18 @@
1
+ import { makeSession } from "#src/auth/testing/make-session.js";
2
+ import { UserRole } from "#src/generated/prisma/client.js";
3
+ import { UserResolver } from "#src/user/user.resolver.js";
4
+
5
+ describe("userResolver", () => {
6
+ it("returns the session user as `me`", () => {
7
+ const resolver = new UserResolver();
8
+
9
+ const me = resolver.me(makeSession("user-42", { email: "me@example.com", name: "" }));
10
+
11
+ expect(me).toMatchObject({
12
+ email: "me@example.com",
13
+ id: "user-42",
14
+ name: null,
15
+ role: [UserRole.USER],
16
+ });
17
+ });
18
+ });
@@ -0,0 +1,24 @@
1
+ import { Query, Resolver } from "@nestjs/graphql";
2
+ import { Session } from "@thallesp/nestjs-better-auth";
3
+ import type { UserSession } from "#src/auth/types/auth.types.js";
4
+ import { UserDto } from "#src/user/dto/user.dto.js";
5
+
6
+ @Resolver(() => UserDto)
7
+ export class UserResolver {
8
+ /**
9
+ * The signed-in user. Requires a session like every route that is not
10
+ * marked `@AllowAnonymous()`.
11
+ */
12
+ @Query(() => UserDto)
13
+ me(@Session() { user }: UserSession): UserDto {
14
+ return {
15
+ createdAt: user.createdAt,
16
+ email: user.email,
17
+ emailVerified: user.emailVerified,
18
+ id: user.id,
19
+ image: user.image ?? null,
20
+ name: user.name || null,
21
+ role: user.role,
22
+ };
23
+ }
24
+ }