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.
- package/dist/index.js +1727 -0
- package/package.json +37 -0
- package/template/.agents/plugins/marketplace.json +21 -0
- package/template/.claude/settings.json +6 -0
- package/template/.claude/skills/frontend-design/LICENSE.txt +177 -0
- package/template/.claude/skills/frontend-design/SKILL.md +154 -0
- package/template/.claude/skills/theme-factory/SKILL.md +95 -0
- package/template/.claude/skills/theme-factory/references/schema.md +104 -0
- package/template/.claude/skills/theme-factory/scripts/theme.mjs +261 -0
- package/template/.dockerignore +43 -0
- package/template/.editorconfig +10 -0
- package/template/.github/workflows/ci.yaml +289 -0
- package/template/.github/workflows/publish-app.yaml +66 -0
- package/template/.github/workflows/publish.yaml +148 -0
- package/template/.github/workflows/snyk.yaml +34 -0
- package/template/.vscode/settings.json +8 -0
- package/template/AGENTS.md +42 -0
- package/template/Dockerfile.base +9 -0
- package/template/README.md +54 -0
- package/template/_gitignore +10 -0
- package/template/apps/api/.env.example +32 -0
- package/template/apps/api/AGENTS.md +44 -0
- package/template/apps/api/Dockerfile +69 -0
- package/template/apps/api/_gitignore +8 -0
- package/template/apps/api/knip.config.ts +22 -0
- package/template/apps/api/kustomize/base/application.properties +16 -0
- package/template/apps/api/kustomize/base/deployment.yaml +94 -0
- package/template/apps/api/kustomize/base/kustomization.yaml +17 -0
- package/template/apps/api/kustomize/base/service.yaml +10 -0
- package/template/apps/api/kustomize/overlays/prod/kustomization.yaml +17 -0
- package/template/apps/api/kustomize/overlays/staging/application.properties +1 -0
- package/template/apps/api/kustomize/overlays/staging/kustomization.yaml +21 -0
- package/template/apps/api/kustomize/secret.keys +14 -0
- package/template/apps/api/oxfmt.config.ts +8 -0
- package/template/apps/api/oxlint.config.ts +107 -0
- package/template/apps/api/package.json +79 -0
- package/template/apps/api/prisma/migrations/20260927000000_init/migration.sql +113 -0
- package/template/apps/api/prisma/migrations/migration_lock.toml +3 -0
- package/template/apps/api/prisma/schema.prisma +113 -0
- package/template/apps/api/prisma.config.ts +7 -0
- package/template/apps/api/scripts/auth.cli.ts +19 -0
- package/template/apps/api/scripts/seal-secret.sh +171 -0
- package/template/apps/api/sonar-project.properties +9 -0
- package/template/apps/api/src/app-info/app-info.dto.ts +10 -0
- package/template/apps/api/src/app-info/app-info.module.ts +7 -0
- package/template/apps/api/src/app-info/app-info.resolver.spec.ts +14 -0
- package/template/apps/api/src/app-info/app-info.resolver.ts +21 -0
- package/template/apps/api/src/app.module.spec.ts +93 -0
- package/template/apps/api/src/app.module.ts +77 -0
- package/template/apps/api/src/auth/auth.guard.spec.ts +90 -0
- package/template/apps/api/src/auth/auth.guard.ts +97 -0
- package/template/apps/api/src/auth/auth.ts +129 -0
- package/template/apps/api/src/auth/decorator/allow-anonymous.decorator.ts +17 -0
- package/template/apps/api/src/auth/decorator/is-email-verified.decorator.spec.ts +41 -0
- package/template/apps/api/src/auth/decorator/is-email-verified.decorator.ts +20 -0
- package/template/apps/api/src/auth/decorator/roles.decorator.ts +27 -0
- package/template/apps/api/src/auth/testing/make-session.ts +31 -0
- package/template/apps/api/src/auth/types/auth.types.ts +27 -0
- package/template/apps/api/src/bootstrap/register-graphql-enums.ts +12 -0
- package/template/apps/api/src/bootstrap/security.ts +24 -0
- package/template/apps/api/src/bootstrap/swagger.ts +28 -0
- package/template/apps/api/src/config/app.config.spec.ts +22 -0
- package/template/apps/api/src/config/app.config.ts +122 -0
- package/template/apps/api/src/config/graphql-depth-limit.ts +103 -0
- package/template/apps/api/src/config/graphql.config.ts +77 -0
- package/template/apps/api/src/config/resources/app.yaml +29 -0
- package/template/apps/api/src/errors/app-parse-uuid.pipe.ts +27 -0
- package/template/apps/api/src/errors/app.exception.ts +47 -0
- package/template/apps/api/src/errors/error-code.enum.ts +54 -0
- package/template/apps/api/src/errors/http-status-fallback-code.util.ts +27 -0
- package/template/apps/api/src/errors/validation-exception.factory.ts +43 -0
- package/template/apps/api/src/health/health.controller.spec.ts +82 -0
- package/template/apps/api/src/health/health.controller.ts +57 -0
- package/template/apps/api/src/health/health.module.ts +12 -0
- package/template/apps/api/src/logging/context-logger.ts +18 -0
- package/template/apps/api/src/logging/logger.config.ts +23 -0
- package/template/apps/api/src/logging/logging.module.ts +22 -0
- package/template/apps/api/src/mail/mail.module.ts +12 -0
- package/template/apps/api/src/mail/mail.service.spec.ts +47 -0
- package/template/apps/api/src/mail/mail.service.ts +123 -0
- package/template/apps/api/src/main.ts +41 -0
- package/template/apps/api/src/prisma/prisma.module.ts +12 -0
- package/template/apps/api/src/prisma/prisma.service.spec.ts +136 -0
- package/template/apps/api/src/prisma/prisma.service.ts +138 -0
- package/template/apps/api/src/queue/dto/enqueue-example.input.ts +11 -0
- package/template/apps/api/src/queue/example-queue.resolver.ts +14 -0
- package/template/apps/api/src/queue/example-queue.service.spec.ts +21 -0
- package/template/apps/api/src/queue/example-queue.service.ts +32 -0
- package/template/apps/api/src/queue/queue.module.ts +27 -0
- package/template/apps/api/src/r2/r2.module.ts +12 -0
- package/template/apps/api/src/r2/r2.service.spec.ts +117 -0
- package/template/apps/api/src/r2/r2.service.ts +131 -0
- package/template/apps/api/src/user/dto/user.dto.ts +26 -0
- package/template/apps/api/src/user/user.module.ts +10 -0
- package/template/apps/api/src/user/user.resolver.spec.ts +18 -0
- package/template/apps/api/src/user/user.resolver.ts +24 -0
- package/template/apps/api/src/utils/catch-everything-exception.filter.spec.ts +292 -0
- package/template/apps/api/src/utils/catch-everything-exception.filter.ts +70 -0
- package/template/apps/api/src/utils/error-handler.util.spec.ts +20 -0
- package/template/apps/api/src/utils/error-handler.util.ts +12 -0
- package/template/apps/api/src/utils/escape-like-pattern.util.spec.ts +29 -0
- package/template/apps/api/src/utils/escape-like-pattern.util.ts +11 -0
- package/template/apps/api/src/utils/pagination.input.ts +20 -0
- package/template/apps/api/src/utils/prisma-exception.filter.spec.ts +186 -0
- package/template/apps/api/src/utils/prisma-exception.filter.ts +45 -0
- package/template/apps/api/src/utils/prisma-graphql-exception.filter.spec.ts +216 -0
- package/template/apps/api/src/utils/prisma-graphql-exception.filter.ts +43 -0
- package/template/apps/api/tests/app.e2e-spec.ts +35 -0
- package/template/apps/api/tests/auth.e2e-spec.ts +38 -0
- package/template/apps/api/tests/helpers/api.ts +27 -0
- package/template/apps/api/tsconfig.json +11 -0
- package/template/apps/api/vitest.config.e2e.ts +16 -0
- package/template/apps/api/vitest.config.ts +40 -0
- package/template/apps/web/.env.example +7 -0
- package/template/apps/web/.storybook/decorators.tsx +38 -0
- package/template/apps/web/.storybook/main.ts +17 -0
- package/template/apps/web/.storybook/preview.ts +38 -0
- package/template/apps/web/.storybook/vitest.setup.ts +5 -0
- package/template/apps/web/AGENTS.md +29 -0
- package/template/apps/web/Dockerfile +49 -0
- package/template/apps/web/_gitignore +16 -0
- package/template/apps/web/knip.config.ts +15 -0
- package/template/apps/web/kustomize/base/application.properties +5 -0
- package/template/apps/web/kustomize/base/deployment.yaml +60 -0
- package/template/apps/web/kustomize/base/kustomization.yaml +17 -0
- package/template/apps/web/kustomize/base/service.yaml +10 -0
- package/template/apps/web/kustomize/overlays/prod/kustomization.yaml +12 -0
- package/template/apps/web/kustomize/overlays/staging/application.properties +2 -0
- package/template/apps/web/kustomize/overlays/staging/kustomization.yaml +18 -0
- package/template/apps/web/messages/de.json +124 -0
- package/template/apps/web/messages/en.json +124 -0
- package/template/apps/web/next-env.d.ts +7 -0
- package/template/apps/web/next.config.ts +39 -0
- package/template/apps/web/oxfmt.config.ts +9 -0
- package/template/apps/web/oxlint.config.ts +36 -0
- package/template/apps/web/package.json +80 -0
- package/template/apps/web/playwright.config.ts +34 -0
- package/template/apps/web/postcss.config.mjs +7 -0
- package/template/apps/web/sonar-project.properties +9 -0
- package/template/apps/web/src/app/[locale]/(auth)/recovery/page.tsx +22 -0
- package/template/apps/web/src/app/[locale]/(auth)/reset-password/page.tsx +29 -0
- package/template/apps/web/src/app/[locale]/(auth)/sign-in/page.tsx +24 -0
- package/template/apps/web/src/app/[locale]/(auth)/sign-up/page.tsx +24 -0
- package/template/apps/web/src/app/[locale]/(auth)/verify/page.tsx +24 -0
- package/template/apps/web/src/app/[locale]/dashboard/page.tsx +34 -0
- package/template/apps/web/src/app/[locale]/layout.tsx +60 -0
- package/template/apps/web/src/app/[locale]/page.tsx +23 -0
- package/template/apps/web/src/app/api/health/route.ts +3 -0
- package/template/apps/web/src/app/global-error.tsx +19 -0
- package/template/apps/web/src/app/globals.css +98 -0
- package/template/apps/web/src/app/icon.svg +1 -0
- package/template/apps/web/src/app/robots.ts +15 -0
- package/template/apps/web/src/app/sitemap.ts +24 -0
- package/template/apps/web/src/components/alert/alert.stories.tsx +25 -0
- package/template/apps/web/src/components/alert/alert.tsx +50 -0
- package/template/apps/web/src/components/avatar/avatar.stories.tsx +116 -0
- package/template/apps/web/src/components/avatar/avatar.tsx +88 -0
- package/template/apps/web/src/components/button/button-variants.ts +28 -0
- package/template/apps/web/src/components/button/button.stories.tsx +33 -0
- package/template/apps/web/src/components/button/button.tsx +32 -0
- package/template/apps/web/src/components/checkbox/checkbox.stories.tsx +312 -0
- package/template/apps/web/src/components/checkbox/checkbox.tsx +38 -0
- package/template/apps/web/src/components/dialog/danger-panel.stories.tsx +41 -0
- package/template/apps/web/src/components/dialog/danger-panel.tsx +32 -0
- package/template/apps/web/src/components/dialog/dialog.stories.tsx +239 -0
- package/template/apps/web/src/components/dialog/dialog.tsx +136 -0
- package/template/apps/web/src/components/dialog-buttons.stories.tsx +55 -0
- package/template/apps/web/src/components/dialog-buttons.tsx +48 -0
- package/template/apps/web/src/components/drawer/drawer.stories.tsx +251 -0
- package/template/apps/web/src/components/drawer/drawer.tsx +120 -0
- package/template/apps/web/src/components/dropdown-menu/dropdown-menu.stories.tsx +329 -0
- package/template/apps/web/src/components/dropdown-menu/dropdown-menu.tsx +263 -0
- package/template/apps/web/src/components/empty-state.stories.tsx +49 -0
- package/template/apps/web/src/components/empty-state.tsx +33 -0
- package/template/apps/web/src/components/field-group.stories.tsx +47 -0
- package/template/apps/web/src/components/field-group.tsx +20 -0
- package/template/apps/web/src/components/field-label.stories.tsx +34 -0
- package/template/apps/web/src/components/field-label.tsx +28 -0
- package/template/apps/web/src/components/icons.tsx +25 -0
- package/template/apps/web/src/components/input/input-otp.stories.tsx +86 -0
- package/template/apps/web/src/components/input/input-otp.tsx +184 -0
- package/template/apps/web/src/components/input/input.stories.tsx +35 -0
- package/template/apps/web/src/components/input/input.tsx +180 -0
- package/template/apps/web/src/components/popover/popover.stories.tsx +479 -0
- package/template/apps/web/src/components/popover/popover.tsx +45 -0
- package/template/apps/web/src/components/responsive-dialog/responsive-dialog.stories.tsx +307 -0
- package/template/apps/web/src/components/responsive-dialog/responsive-dialog.tsx +96 -0
- package/template/apps/web/src/components/searchbar/searchbar.stories.tsx +59 -0
- package/template/apps/web/src/components/searchbar/searchbar.tsx +64 -0
- package/template/apps/web/src/components/select/select.stories.tsx +293 -0
- package/template/apps/web/src/components/select/select.tsx +445 -0
- package/template/apps/web/src/components/skeleton/skeleton.stories.tsx +76 -0
- package/template/apps/web/src/components/skeleton/skeleton.tsx +13 -0
- package/template/apps/web/src/components/spinner/spinner.tsx +18 -0
- package/template/apps/web/src/components/switch/switch.stories.tsx +71 -0
- package/template/apps/web/src/components/switch/switch.tsx +35 -0
- package/template/apps/web/src/components/tabs/tabs.stories.tsx +209 -0
- package/template/apps/web/src/components/tabs/tabs.tsx +69 -0
- package/template/apps/web/src/components/tabs/url-tabs.stories.tsx +68 -0
- package/template/apps/web/src/components/tabs/url-tabs.tsx +42 -0
- package/template/apps/web/src/components/textarea/textarea.stories.tsx +144 -0
- package/template/apps/web/src/components/textarea/textarea.tsx +115 -0
- package/template/apps/web/src/components/toast/toaster.tsx +38 -0
- package/template/apps/web/src/components/tooltip/tooltip.stories.tsx +237 -0
- package/template/apps/web/src/components/tooltip/tooltip.tsx +49 -0
- package/template/apps/web/src/config/csp.ts +59 -0
- package/template/apps/web/src/config/routes.ts +14 -0
- package/template/apps/web/src/config/site.ts +5 -0
- package/template/apps/web/src/env.ts +15 -0
- package/template/apps/web/src/features/auth/auth-card.tsx +20 -0
- package/template/apps/web/src/features/auth/auth-divider.tsx +14 -0
- package/template/apps/web/src/features/auth/google-auth-button.tsx +49 -0
- package/template/apps/web/src/features/auth/google-one-tap.tsx +23 -0
- package/template/apps/web/src/features/auth/lib/auth-error.test.ts +22 -0
- package/template/apps/web/src/features/auth/lib/auth-error.ts +16 -0
- package/template/apps/web/src/features/auth/lib/return-to.test.ts +41 -0
- package/template/apps/web/src/features/auth/lib/return-to.ts +19 -0
- package/template/apps/web/src/features/auth/lib/validator.test.ts +45 -0
- package/template/apps/web/src/features/auth/lib/validator.ts +49 -0
- package/template/apps/web/src/features/auth/passkey-settings.tsx +106 -0
- package/template/apps/web/src/features/auth/recovery-form.tsx +61 -0
- package/template/apps/web/src/features/auth/resend-verification-button.tsx +36 -0
- package/template/apps/web/src/features/auth/reset-password-form.tsx +62 -0
- package/template/apps/web/src/features/auth/sign-in-form.stories.tsx +22 -0
- package/template/apps/web/src/features/auth/sign-in-form.tsx +116 -0
- package/template/apps/web/src/features/auth/sign-out-button.tsx +26 -0
- package/template/apps/web/src/features/auth/sign-up-form.tsx +107 -0
- package/template/apps/web/src/features/shared/provider.tsx +16 -0
- package/template/apps/web/src/features/shared/site-header.tsx +48 -0
- package/template/apps/web/src/features/shared/theme-toggle.tsx +24 -0
- package/template/apps/web/src/i18n/navigation.ts +5 -0
- package/template/apps/web/src/i18n/request.ts +21 -0
- package/template/apps/web/src/i18n/routing.ts +6 -0
- package/template/apps/web/src/lib/auth-client.ts +19 -0
- package/template/apps/web/src/lib/auth-server.ts +46 -0
- package/template/apps/web/src/lib/auth.ts +8 -0
- package/template/apps/web/src/lib/codegen.ts +30 -0
- package/template/apps/web/src/lib/graphql-client.ts +29 -0
- package/template/apps/web/src/lib/graphql-server.ts +28 -0
- package/template/apps/web/src/lib/graphql-utils.test.ts +32 -0
- package/template/apps/web/src/lib/graphql-utils.ts +76 -0
- package/template/apps/web/src/lib/hooks/use-media-query.ts +21 -0
- package/template/apps/web/src/lib/metadata.ts +17 -0
- package/template/apps/web/src/lib/network-error.test.ts +30 -0
- package/template/apps/web/src/lib/network-error.ts +17 -0
- package/template/apps/web/src/lib/proxy-utils.ts +42 -0
- package/template/apps/web/src/lib/query-client.ts +29 -0
- package/template/apps/web/src/lib/utils.ts +5 -0
- package/template/apps/web/src/proxy.ts +57 -0
- package/template/apps/web/test-results/.last-run.json +4 -0
- package/template/apps/web/tests/auth.spec.ts +17 -0
- package/template/apps/web/tests/csp-theme-script.spec.ts +37 -0
- package/template/apps/web/tests/home.spec.ts +25 -0
- package/template/apps/web/tsconfig.build.json +4 -0
- package/template/apps/web/tsconfig.json +28 -0
- package/template/apps/web/vitest.config.ts +40 -0
- package/template/apps/web/vitest.shims.d.ts +1 -0
- package/template/apps/worker/.env.example +4 -0
- package/template/apps/worker/AGENTS.md +11 -0
- package/template/apps/worker/Dockerfile +35 -0
- package/template/apps/worker/knip.config.ts +3 -0
- package/template/apps/worker/kustomize/base/application.properties +4 -0
- package/template/apps/worker/kustomize/base/deployment.yaml +62 -0
- package/template/apps/worker/kustomize/base/kustomization.yaml +16 -0
- package/template/apps/worker/kustomize/overlays/prod/kustomization.yaml +12 -0
- package/template/apps/worker/kustomize/overlays/staging/application.properties +1 -0
- package/template/apps/worker/kustomize/overlays/staging/kustomization.yaml +18 -0
- package/template/apps/worker/oxfmt.config.ts +8 -0
- package/template/apps/worker/oxlint.config.ts +15 -0
- package/template/apps/worker/package.json +33 -0
- package/template/apps/worker/sonar-project.properties +8 -0
- package/template/apps/worker/src/env.ts +13 -0
- package/template/apps/worker/src/lib/graceful-shutdown.ts +27 -0
- package/template/apps/worker/src/lib/logger.ts +20 -0
- package/template/apps/worker/src/main.ts +17 -0
- package/template/apps/worker/src/processors/example-processor.ts +15 -0
- package/template/apps/worker/src/worker/redis.ts +19 -0
- package/template/apps/worker/src/worker/worker.ts +37 -0
- package/template/apps/worker/tests/example-processor.test.ts +13 -0
- package/template/apps/worker/tests/graceful-shutdown.test.ts +34 -0
- package/template/apps/worker/tsconfig.json +4 -0
- package/template/apps/worker/vitest.config.ts +13 -0
- package/template/bun.lock +3053 -0
- package/template/bunfig.toml +2 -0
- package/template/compose.yaml +53 -0
- package/template/docker-bake.hcl +86 -0
- package/template/package.json +47 -0
- package/template/packages/lint/knip.ts +40 -0
- package/template/packages/lint/oxfmt.ts +8 -0
- package/template/packages/lint/oxlint.ts +136 -0
- package/template/packages/lint/package.json +13 -0
- package/template/packages/queues/oxfmt.config.ts +8 -0
- package/template/packages/queues/oxlint.config.ts +7 -0
- package/template/packages/queues/package.json +15 -0
- package/template/packages/queues/src/example-job.ts +11 -0
- package/template/packages/queues/src/index.ts +3 -0
- package/template/packages/queues/src/queue-names.ts +2 -0
- package/template/packages/queues/src/redis-connection.ts +27 -0
- package/template/packages/queues/tsconfig.json +4 -0
- package/template/packages/tsconfig/base.json +23 -0
- package/template/packages/tsconfig/node.json +11 -0
- package/template/packages/tsconfig/package.json +4 -0
- 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
|
+
}
|