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,216 @@
|
|
|
1
|
+
import type { TestingModule } from "@nestjs/testing";
|
|
2
|
+
import { Test } from "@nestjs/testing";
|
|
3
|
+
import { GraphQLError } from "graphql";
|
|
4
|
+
import { vi } from "vitest";
|
|
5
|
+
import type { MockInstance } from "vitest";
|
|
6
|
+
import { ErrorCode } from "#src/errors/error-code.enum.js";
|
|
7
|
+
import { Prisma } from "#src/generated/prisma/client.js";
|
|
8
|
+
import { ContextLogger } from "#src/logging/context-logger.js";
|
|
9
|
+
import { PrismaGraphQLExceptionFilter } from "#src/utils/prisma-graphql-exception.filter.js";
|
|
10
|
+
|
|
11
|
+
describe("prismaGraphQLExceptionFilter", () => {
|
|
12
|
+
let filter: PrismaGraphQLExceptionFilter;
|
|
13
|
+
let loggerErrorSpy: MockInstance;
|
|
14
|
+
|
|
15
|
+
beforeEach(async () => {
|
|
16
|
+
const mockLogger = { debug: vi.fn(), error: vi.fn(), setContext: vi.fn() };
|
|
17
|
+
|
|
18
|
+
const module: TestingModule = await Test.createTestingModule({
|
|
19
|
+
providers: [PrismaGraphQLExceptionFilter, { provide: ContextLogger, useValue: mockLogger }],
|
|
20
|
+
}).compile();
|
|
21
|
+
|
|
22
|
+
filter = module.get<PrismaGraphQLExceptionFilter>(PrismaGraphQLExceptionFilter);
|
|
23
|
+
loggerErrorSpy = mockLogger.error;
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
vi.clearAllMocks();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("catch", () => {
|
|
31
|
+
it("should handle PrismaClientKnownRequestError and return GraphQLError", () => {
|
|
32
|
+
const prismaError = new Prisma.PrismaClientKnownRequestError(
|
|
33
|
+
"Unique constraint failed on the fields: (`email`)",
|
|
34
|
+
{
|
|
35
|
+
clientVersion: "5.0.0",
|
|
36
|
+
code: "P2002",
|
|
37
|
+
},
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const result = filter.catch(prismaError);
|
|
41
|
+
|
|
42
|
+
expect(result).toBeInstanceOf(GraphQLError);
|
|
43
|
+
expect(result.message).toBe(ErrorCode.INTERNAL_SERVER_ERROR);
|
|
44
|
+
expect(result.extensions?.code).toBe(ErrorCode.INTERNAL_SERVER_ERROR);
|
|
45
|
+
expect(loggerErrorSpy).toHaveBeenCalledWith(
|
|
46
|
+
{ error: prismaError.message, stack: prismaError.stack },
|
|
47
|
+
"Prisma error in GraphQL context",
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("should handle PrismaClientUnknownRequestError and return GraphQLError", () => {
|
|
52
|
+
const prismaError = new Prisma.PrismaClientUnknownRequestError("Unknown database error", {
|
|
53
|
+
clientVersion: "5.0.0",
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const result = filter.catch(prismaError);
|
|
57
|
+
|
|
58
|
+
expect(result).toBeInstanceOf(GraphQLError);
|
|
59
|
+
expect(result.message).toBe(ErrorCode.INTERNAL_SERVER_ERROR);
|
|
60
|
+
expect(result.extensions?.code).toBe(ErrorCode.INTERNAL_SERVER_ERROR);
|
|
61
|
+
expect(loggerErrorSpy).toHaveBeenCalledWith(
|
|
62
|
+
{ error: prismaError.message, stack: prismaError.stack },
|
|
63
|
+
"Prisma error in GraphQL context",
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("should handle PrismaClientValidationError and return GraphQLError", () => {
|
|
68
|
+
const prismaError = new Prisma.PrismaClientValidationError(
|
|
69
|
+
"Invalid query: missing required field",
|
|
70
|
+
{ clientVersion: "5.0.0" },
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const result = filter.catch(prismaError);
|
|
74
|
+
|
|
75
|
+
expect(result).toBeInstanceOf(GraphQLError);
|
|
76
|
+
expect(result.message).toBe(ErrorCode.INTERNAL_SERVER_ERROR);
|
|
77
|
+
expect(result.extensions?.code).toBe(ErrorCode.INTERNAL_SERVER_ERROR);
|
|
78
|
+
expect(loggerErrorSpy).toHaveBeenCalledWith(
|
|
79
|
+
{ error: prismaError.message, stack: prismaError.stack },
|
|
80
|
+
"Prisma error in GraphQL context",
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("should log error message and stack for Error instances", () => {
|
|
85
|
+
const error = new Error("Test error message");
|
|
86
|
+
|
|
87
|
+
filter.catch(error);
|
|
88
|
+
|
|
89
|
+
expect(loggerErrorSpy).toHaveBeenCalledWith(
|
|
90
|
+
{ error: "Test error message", stack: error.stack },
|
|
91
|
+
"Prisma error in GraphQL context",
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("should log string representation for non-Error exceptions", () => {
|
|
96
|
+
const exception = { someProperty: "some value" };
|
|
97
|
+
|
|
98
|
+
filter.catch(exception);
|
|
99
|
+
|
|
100
|
+
expect(loggerErrorSpy).toHaveBeenCalledWith(
|
|
101
|
+
{ error: "[object Object]", stack: undefined },
|
|
102
|
+
"Prisma error in GraphQL context",
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("should log for null exceptions", () => {
|
|
107
|
+
filter.catch(null);
|
|
108
|
+
|
|
109
|
+
expect(loggerErrorSpy).toHaveBeenCalledWith(
|
|
110
|
+
{ error: "null", stack: undefined },
|
|
111
|
+
"Prisma error in GraphQL context",
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("should log for undefined exceptions", () => {
|
|
116
|
+
filter.catch(undefined);
|
|
117
|
+
|
|
118
|
+
expect(loggerErrorSpy).toHaveBeenCalledWith(
|
|
119
|
+
{ error: "undefined", stack: undefined },
|
|
120
|
+
"Prisma error in GraphQL context",
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("should always return same GraphQLError structure", () => {
|
|
125
|
+
const error1 = new Prisma.PrismaClientKnownRequestError("Error 1", {
|
|
126
|
+
clientVersion: "5.0.0",
|
|
127
|
+
code: "P2002",
|
|
128
|
+
});
|
|
129
|
+
const error2 = new Prisma.PrismaClientValidationError("Error 2", {
|
|
130
|
+
clientVersion: "5.0.0",
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const result1 = filter.catch(error1);
|
|
134
|
+
const result2 = filter.catch(error2);
|
|
135
|
+
|
|
136
|
+
expect(result1.message).toBe(result2.message);
|
|
137
|
+
expect(result1.extensions?.code).toBe(result2.extensions?.code);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("should handle PrismaClientKnownRequestError with P2025 code", () => {
|
|
141
|
+
const prismaError = new Prisma.PrismaClientKnownRequestError(
|
|
142
|
+
"Record to delete does not exist",
|
|
143
|
+
{
|
|
144
|
+
clientVersion: "5.0.0",
|
|
145
|
+
code: "P2025",
|
|
146
|
+
},
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
const result = filter.catch(prismaError);
|
|
150
|
+
|
|
151
|
+
expect(result).toBeInstanceOf(GraphQLError);
|
|
152
|
+
expect(result.message).toBe(ErrorCode.INTERNAL_SERVER_ERROR);
|
|
153
|
+
expect(loggerErrorSpy).toHaveBeenCalled();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("should handle PrismaClientKnownRequestError with P2003 code", () => {
|
|
157
|
+
const prismaError = new Prisma.PrismaClientKnownRequestError(
|
|
158
|
+
"Foreign key constraint failed",
|
|
159
|
+
{
|
|
160
|
+
clientVersion: "5.0.0",
|
|
161
|
+
code: "P2003",
|
|
162
|
+
},
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
const result = filter.catch(prismaError);
|
|
166
|
+
|
|
167
|
+
expect(result).toBeInstanceOf(GraphQLError);
|
|
168
|
+
expect(result.message).toBe(ErrorCode.INTERNAL_SERVER_ERROR);
|
|
169
|
+
expect(loggerErrorSpy).toHaveBeenCalledWith(
|
|
170
|
+
{ error: prismaError.message, stack: prismaError.stack },
|
|
171
|
+
"Prisma error in GraphQL context",
|
|
172
|
+
);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("should not expose internal error details in GraphQL response", () => {
|
|
176
|
+
const prismaError = new Prisma.PrismaClientKnownRequestError(
|
|
177
|
+
"Sensitive database information: user_id=123, password=secret",
|
|
178
|
+
{
|
|
179
|
+
clientVersion: "5.0.0",
|
|
180
|
+
code: "P2002",
|
|
181
|
+
},
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
const result = filter.catch(prismaError);
|
|
185
|
+
|
|
186
|
+
expect(result.message).toBe(ErrorCode.INTERNAL_SERVER_ERROR);
|
|
187
|
+
expect(result.message).not.toContain("Sensitive");
|
|
188
|
+
expect(result.message).not.toContain("password");
|
|
189
|
+
expect(result.message).not.toContain("user_id");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("should handle string exceptions", () => {
|
|
193
|
+
const stringException = "Simple string error";
|
|
194
|
+
|
|
195
|
+
const result = filter.catch(stringException);
|
|
196
|
+
|
|
197
|
+
expect(result).toBeInstanceOf(GraphQLError);
|
|
198
|
+
expect(loggerErrorSpy).toHaveBeenCalledWith(
|
|
199
|
+
{ error: "Simple string error", stack: undefined },
|
|
200
|
+
"Prisma error in GraphQL context",
|
|
201
|
+
);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("should handle number exceptions", () => {
|
|
205
|
+
const numberException = 404;
|
|
206
|
+
|
|
207
|
+
const result = filter.catch(numberException);
|
|
208
|
+
|
|
209
|
+
expect(result).toBeInstanceOf(GraphQLError);
|
|
210
|
+
expect(loggerErrorSpy).toHaveBeenCalledWith(
|
|
211
|
+
{ error: "404", stack: undefined },
|
|
212
|
+
"Prisma error in GraphQL context",
|
|
213
|
+
);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Catch } from "@nestjs/common";
|
|
2
|
+
import type { GqlExceptionFilter } from "@nestjs/graphql";
|
|
3
|
+
import { GraphQLError } from "graphql";
|
|
4
|
+
import { ErrorCode } from "#src/errors/error-code.enum.js";
|
|
5
|
+
import { Prisma } from "#src/generated/prisma/client.js";
|
|
6
|
+
import { ContextLogger } from "#src/logging/context-logger.js";
|
|
7
|
+
import { handleError } from "#src/utils/error-handler.util.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* GraphQL exception filter for handling Prisma database errors in GraphQL contexts.
|
|
11
|
+
*
|
|
12
|
+
* This filter intercepts Prisma client errors that occur during GraphQL query/mutation
|
|
13
|
+
* execution and transforms them into standardized GraphQL errors. It prevents sensitive
|
|
14
|
+
* database information from being exposed to clients by returning generic error messages.
|
|
15
|
+
*/
|
|
16
|
+
@Catch(
|
|
17
|
+
Prisma.PrismaClientKnownRequestError,
|
|
18
|
+
Prisma.PrismaClientUnknownRequestError,
|
|
19
|
+
Prisma.PrismaClientValidationError,
|
|
20
|
+
)
|
|
21
|
+
export class PrismaGraphQLExceptionFilter implements GqlExceptionFilter {
|
|
22
|
+
constructor(readonly logger: ContextLogger) {}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Catches and processes Prisma errors in GraphQL context.
|
|
26
|
+
*
|
|
27
|
+
* @param exception - The Prisma error that was thrown
|
|
28
|
+
* @returns A GraphQL error with sanitized error information
|
|
29
|
+
*/
|
|
30
|
+
catch(exception: unknown) {
|
|
31
|
+
this.logger.error(
|
|
32
|
+
{
|
|
33
|
+
error: handleError(exception),
|
|
34
|
+
stack: exception instanceof Error ? exception.stack : undefined,
|
|
35
|
+
},
|
|
36
|
+
"Prisma error in GraphQL context",
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
return new GraphQLError(ErrorCode.INTERNAL_SERVER_ERROR, {
|
|
40
|
+
extensions: { code: ErrorCode.INTERNAL_SERVER_ERROR },
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { API_URL, graphql } from "#tests/helpers/api.js";
|
|
2
|
+
|
|
3
|
+
describe("app (e2e)", () => {
|
|
4
|
+
describe("health", () => {
|
|
5
|
+
it("is ready", async () => {
|
|
6
|
+
const response = await fetch(`${API_URL}/health`);
|
|
7
|
+
|
|
8
|
+
expect(response.status).toBe(200);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("is live", async () => {
|
|
12
|
+
const response = await fetch(`${API_URL}/health/live`);
|
|
13
|
+
|
|
14
|
+
expect(response.status).toBe(200);
|
|
15
|
+
await expect(response.json()).resolves.toMatchObject({ status: "ok" });
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("graphql", () => {
|
|
20
|
+
it("answers the public appInfo query", async () => {
|
|
21
|
+
const { data, errors } = await graphql<{ appInfo: { title: string } }>(
|
|
22
|
+
"query { appInfo { title version } }",
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
expect(errors).toBeUndefined();
|
|
26
|
+
expect(data?.appInfo.title).toBeTruthy();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("rejects malformed input with a machine-readable code", async () => {
|
|
30
|
+
const { errors } = await graphql("query { doesNotExist }");
|
|
31
|
+
|
|
32
|
+
expect(errors?.[0]?.extensions?.code).toBeTruthy();
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { API_URL, graphql } from "#tests/helpers/api.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Needs the API running with NODE_ENV=test so sign-up skips the verification
|
|
6
|
+
* mail (see `buildBaseAuthOptions`).
|
|
7
|
+
*/
|
|
8
|
+
describe("auth (e2e)", () => {
|
|
9
|
+
const email = `e2e-${randomUUID()}@example.com`;
|
|
10
|
+
const password = "correct-horse-battery-staple";
|
|
11
|
+
|
|
12
|
+
async function signUp(): Promise<string> {
|
|
13
|
+
const response = await fetch(`${API_URL}/api/auth/sign-up/email`, {
|
|
14
|
+
body: JSON.stringify({ email, name: "E2E", password }),
|
|
15
|
+
headers: { "Content-Type": "application/json", Origin: "http://localhost:3000" },
|
|
16
|
+
method: "POST",
|
|
17
|
+
});
|
|
18
|
+
expect(response.status).toBe(200);
|
|
19
|
+
return response.headers.getSetCookie().join("; ");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
it("rejects `me` without a session", async () => {
|
|
23
|
+
const { errors } = await graphql("query { me { id } }");
|
|
24
|
+
|
|
25
|
+
expect(errors?.[0]?.extensions?.code).toBe("UNAUTHORIZED");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("signs up and resolves `me` from the session cookie", async () => {
|
|
29
|
+
const cookie = await signUp();
|
|
30
|
+
|
|
31
|
+
const { data, errors } = await graphql<{ me: { email: string } }>("query { me { email } }", {
|
|
32
|
+
cookie,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
expect(errors).toBeUndefined();
|
|
36
|
+
expect(data?.me.email).toBe(email);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export const API_URL = process.env.API_URL ?? "http://localhost:3001";
|
|
2
|
+
|
|
3
|
+
type GraphQLResponse<TData> = {
|
|
4
|
+
data?: TData;
|
|
5
|
+
errors?: { extensions?: { code?: string; params?: unknown }; message: string }[];
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Sends one GraphQL operation to the running API.
|
|
10
|
+
*
|
|
11
|
+
* @param query - The operation document
|
|
12
|
+
* @param options - Variables and an optional `Cookie` header value (the session)
|
|
13
|
+
*/
|
|
14
|
+
export async function graphql<TData>(
|
|
15
|
+
query: string,
|
|
16
|
+
{ cookie, variables }: { cookie?: string; variables?: Record<string, unknown> } = {},
|
|
17
|
+
): Promise<GraphQLResponse<TData>> {
|
|
18
|
+
const response = await fetch(`${API_URL}/graphql`, {
|
|
19
|
+
body: JSON.stringify({ query, variables }),
|
|
20
|
+
headers: {
|
|
21
|
+
"Content-Type": "application/json",
|
|
22
|
+
...(cookie ? { Cookie: cookie } : {}),
|
|
23
|
+
},
|
|
24
|
+
method: "POST",
|
|
25
|
+
});
|
|
26
|
+
return (await response.json()) as GraphQLResponse<TData>;
|
|
27
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "@nexst/tsconfig/node.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"allowImportingTsExtensions": true,
|
|
5
|
+
"emitDecoratorMetadata": true,
|
|
6
|
+
"experimentalDecorators": true,
|
|
7
|
+
"strictPropertyInitialization": false,
|
|
8
|
+
"types": ["vitest/globals", "node"]
|
|
9
|
+
},
|
|
10
|
+
"include": ["**/*.ts"]
|
|
11
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { defineConfig } from "vitest/config";
|
|
3
|
+
|
|
4
|
+
if (existsSync(".env.development")) process.loadEnvFile(".env.development");
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* E2E specs talk to a running API (`bun dev` locally, the rolled-out image in
|
|
8
|
+
* CI) at `API_URL`, default http://localhost:3001.
|
|
9
|
+
*/
|
|
10
|
+
export default defineConfig({
|
|
11
|
+
test: {
|
|
12
|
+
globals: true,
|
|
13
|
+
include: ["tests/**/*.e2e-spec.ts"],
|
|
14
|
+
root: "./",
|
|
15
|
+
},
|
|
16
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { fileURLToPath } from "node:url";
|
|
2
|
+
import { defineConfig } from "vitest/config";
|
|
3
|
+
|
|
4
|
+
export default defineConfig({
|
|
5
|
+
resolve: {
|
|
6
|
+
// graphql ships a "development" export condition; without pinning one entry,
|
|
7
|
+
// @nestjs/graphql and the app load two graphql instances ("Cannot use
|
|
8
|
+
// GraphQLSchema from another module or realm").
|
|
9
|
+
alias: [{ find: /^graphql$/, replacement: fileURLToPath(import.meta.resolve("graphql")) }],
|
|
10
|
+
},
|
|
11
|
+
test: {
|
|
12
|
+
coverage: {
|
|
13
|
+
exclude: ["src/generated/**", "**/*.spec.ts"],
|
|
14
|
+
include: ["src/**/*.ts"],
|
|
15
|
+
provider: "v8",
|
|
16
|
+
reporter: ["text", "lcov"],
|
|
17
|
+
},
|
|
18
|
+
// Unit tests never reach real services; these only satisfy config validation.
|
|
19
|
+
env: {
|
|
20
|
+
// @if auth
|
|
21
|
+
BETTER_AUTH_SECRET: "unit-test-secret-unit-test-secret-1234",
|
|
22
|
+
BETTER_AUTH_URL: "http://localhost:3001",
|
|
23
|
+
// @endif
|
|
24
|
+
DATABASE_URL: "postgresql://user:pass@localhost:5432/db",
|
|
25
|
+
// @if r2
|
|
26
|
+
R2_ACCESS_KEY_ID: "unit-test",
|
|
27
|
+
R2_BUCKET: "unit-test",
|
|
28
|
+
R2_ENDPOINT: "https://unit-test.r2.cloudflarestorage.com",
|
|
29
|
+
R2_SECRET_ACCESS_KEY: "unit-test",
|
|
30
|
+
// @endif
|
|
31
|
+
},
|
|
32
|
+
globals: true,
|
|
33
|
+
include: ["src/**/*.spec.ts"],
|
|
34
|
+
isolate: false,
|
|
35
|
+
root: "./",
|
|
36
|
+
// @if worker
|
|
37
|
+
server: { deps: { inline: ["@nestjs/bullmq"] } },
|
|
38
|
+
// @endif
|
|
39
|
+
},
|
|
40
|
+
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Copy to .env (bun/next load it automatically) and adjust.
|
|
2
|
+
NEXT_PUBLIC_API_URL=http://localhost:3001
|
|
3
|
+
NEXT_PUBLIC_HOST_URL=http://localhost:3000
|
|
4
|
+
# @if auth
|
|
5
|
+
# Google sign-in (button + One Tap); same client ID as GOOGLE_CLIENT_ID in apps/api. Hidden while unset.
|
|
6
|
+
# NEXT_PUBLIC_GOOGLE_CLIENT_ID=<id>.apps.googleusercontent.com
|
|
7
|
+
# @endif
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Storybook decorators. Stories import their providers from here so every
|
|
3
|
+
* story renders inside the same shell as the app.
|
|
4
|
+
*/
|
|
5
|
+
import type { Decorator } from "@storybook/nextjs-vite";
|
|
6
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
7
|
+
import { NextIntlClientProvider } from "next-intl";
|
|
8
|
+
import type { ComponentProps } from "react";
|
|
9
|
+
import enMessages from "../messages/en.json";
|
|
10
|
+
|
|
11
|
+
type IntlMessages = ComponentProps<typeof NextIntlClientProvider>["messages"];
|
|
12
|
+
|
|
13
|
+
/** Centers the story and constrains its width — handy for form/card components. */
|
|
14
|
+
export function withContainer(className = "w-96"): Decorator {
|
|
15
|
+
return (Story) => (
|
|
16
|
+
<div className={className}>
|
|
17
|
+
<Story />
|
|
18
|
+
</div>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function makeQueryClient() {
|
|
23
|
+
return new QueryClient({
|
|
24
|
+
defaultOptions: {
|
|
25
|
+
mutations: { retry: false },
|
|
26
|
+
queries: { retry: false, staleTime: Number.POSITIVE_INFINITY },
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Intl (English catalog) + a fresh React Query client. Registered globally in preview.ts. */
|
|
32
|
+
export const withProviders: Decorator = (Story) => (
|
|
33
|
+
<QueryClientProvider client={makeQueryClient()}>
|
|
34
|
+
<NextIntlClientProvider locale="en" messages={enMessages as IntlMessages}>
|
|
35
|
+
<Story />
|
|
36
|
+
</NextIntlClientProvider>
|
|
37
|
+
</QueryClientProvider>
|
|
38
|
+
);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { StorybookConfig } from "@storybook/nextjs-vite";
|
|
2
|
+
|
|
3
|
+
const config: StorybookConfig = {
|
|
4
|
+
addons: [
|
|
5
|
+
"@storybook/addon-docs",
|
|
6
|
+
"@storybook/addon-a11y",
|
|
7
|
+
"@storybook/addon-vitest",
|
|
8
|
+
"@storybook/addon-themes",
|
|
9
|
+
],
|
|
10
|
+
framework: {
|
|
11
|
+
name: "@storybook/nextjs-vite",
|
|
12
|
+
options: {},
|
|
13
|
+
},
|
|
14
|
+
staticDirs: ["../public"],
|
|
15
|
+
stories: ["../src/**/*.stories.@(ts|tsx)"],
|
|
16
|
+
};
|
|
17
|
+
export default config;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { withThemeByClassName } from "@storybook/addon-themes";
|
|
2
|
+
import type { Preview } from "@storybook/nextjs-vite";
|
|
3
|
+
import { withProviders } from "./decorators";
|
|
4
|
+
import "../src/app/globals.css";
|
|
5
|
+
|
|
6
|
+
const preview: Preview = {
|
|
7
|
+
decorators: [
|
|
8
|
+
withProviders,
|
|
9
|
+
// Globals are "Light"/"Dark" (capitalised) — the addon crashes on other values.
|
|
10
|
+
withThemeByClassName({
|
|
11
|
+
defaultTheme: "Light",
|
|
12
|
+
themes: {
|
|
13
|
+
Dark: "dark",
|
|
14
|
+
Light: "",
|
|
15
|
+
},
|
|
16
|
+
}),
|
|
17
|
+
],
|
|
18
|
+
parameters: {
|
|
19
|
+
a11y: {
|
|
20
|
+
// Every story is an axe check in `bun run test:unit`; violations fail the run.
|
|
21
|
+
test: "error",
|
|
22
|
+
},
|
|
23
|
+
controls: {
|
|
24
|
+
matchers: {
|
|
25
|
+
color: /(background|color)$/i,
|
|
26
|
+
date: /Date$/i,
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
nextjs: {
|
|
30
|
+
appDirectory: true,
|
|
31
|
+
navigation: {
|
|
32
|
+
pathname: "/",
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export default preview;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<!-- BEGIN:nextjs-agent-rules -->
|
|
2
|
+
|
|
3
|
+
# This is NOT the Next.js you know
|
|
4
|
+
|
|
5
|
+
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
|
|
6
|
+
|
|
7
|
+
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
|
|
8
|
+
|
|
9
|
+
<!-- END:nextjs-agent-rules -->
|
|
10
|
+
|
|
11
|
+
# web (Next.js)
|
|
12
|
+
|
|
13
|
+
- i18n via next-intl: every user-facing string goes through `messages/*.json`, in every
|
|
14
|
+
locale file. Routes live under `src/app/[locale]`; use `Link`/`useRouter` from
|
|
15
|
+
`@/i18n/navigation`.
|
|
16
|
+
- Styling: Tailwind 4 with the semantic tokens in `src/app/globals.css`
|
|
17
|
+
(`bg-background`, `text-muted-foreground`, …) — no raw hex or ramp steps in
|
|
18
|
+
components. Reuse `src/components/*`.
|
|
19
|
+
- CSP lives in `src/config/csp.ts` and is applied by `src/proxy.ts`. New external
|
|
20
|
+
origins (API, CDN, images) must be added there. `tests/csp-theme-script.spec.ts`
|
|
21
|
+
guards the inline-script hashes.
|
|
22
|
+
- API calls: `@/lib/graphql-client` (browser, cookies included) or
|
|
23
|
+
`@/lib/graphql-server` (server components, forward the cookie). Type documents with
|
|
24
|
+
`graphql()` from `@/generated/gql` after `bun run codegen` (API must be running);
|
|
25
|
+
never hand-type `TypedDocumentNode`s.
|
|
26
|
+
- Server-only env in `src/env.ts` (`@t3-oss/env-nextjs`); `NEXT_PUBLIC_*` are baked in
|
|
27
|
+
at build time (Docker build args).
|
|
28
|
+
- Tests: `bun run test:unit` (Vitest unit + Storybook stories incl. axe a11y),
|
|
29
|
+
`bun run test` (Playwright against `next start`).
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
ARG BUN_VERSION
|
|
2
|
+
|
|
3
|
+
# ---------------------------------------------------
|
|
4
|
+
# Stage 1: Pruner
|
|
5
|
+
# ---------------------------------------------------
|
|
6
|
+
FROM toolchain AS pruner
|
|
7
|
+
RUN turbo prune @nexst/web --docker
|
|
8
|
+
|
|
9
|
+
# ---------------------------------------------------
|
|
10
|
+
# Stage 2: Builder
|
|
11
|
+
# ---------------------------------------------------
|
|
12
|
+
FROM dhi.io/bun:${BUN_VERSION}-alpine-dev AS builder
|
|
13
|
+
WORKDIR /app
|
|
14
|
+
COPY --from=pruner /app/out/json/ .
|
|
15
|
+
RUN --mount=type=cache,id=bun-install,sharing=locked,target=/root/.bun/install/cache \
|
|
16
|
+
bun install --frozen-lockfile
|
|
17
|
+
COPY --from=pruner /app/out/full/ .
|
|
18
|
+
|
|
19
|
+
ARG NEXT_PUBLIC_HOST_URL
|
|
20
|
+
ARG NEXT_PUBLIC_API_URL
|
|
21
|
+
# @if auth
|
|
22
|
+
ARG NEXT_PUBLIC_GOOGLE_CLIENT_ID
|
|
23
|
+
# @endif
|
|
24
|
+
|
|
25
|
+
RUN cd apps/web && \
|
|
26
|
+
bun run build && \
|
|
27
|
+
cd .next/standalone/node_modules/.bun && \
|
|
28
|
+
rm -rf sharp@* @img+sharp-* node_modules/sharp node_modules/@img/sharp-* next@*/node_modules/sharp
|
|
29
|
+
|
|
30
|
+
# ---------------------------------------------------
|
|
31
|
+
# Stage 3: Runtime
|
|
32
|
+
# ---------------------------------------------------
|
|
33
|
+
FROM dhi.io/bun:${BUN_VERSION}-alpine AS runtime
|
|
34
|
+
WORKDIR /app
|
|
35
|
+
ENV NODE_ENV=production \
|
|
36
|
+
NEXT_TELEMETRY_DISABLED=1 \
|
|
37
|
+
HOSTNAME=0.0.0.0 \
|
|
38
|
+
PORT=3000
|
|
39
|
+
|
|
40
|
+
COPY --from=builder --chown=nonroot:nonroot /app/apps/web/.next/standalone ./
|
|
41
|
+
COPY --from=builder --chown=nonroot:nonroot /app/apps/web/.next/static ./apps/web/.next/static
|
|
42
|
+
COPY --from=builder --chown=nonroot:nonroot /app/apps/web/public ./apps/web/public
|
|
43
|
+
|
|
44
|
+
HEALTHCHECK --interval=30s --timeout=3s --start-period=15s --retries=3 \
|
|
45
|
+
CMD ["bun", "-e", "require('http').get('http://localhost:3000/api/health', (res) => { process.exit(res.statusCode === 200 ? 0 : 1) }).on('error', () => process.exit(1))"]
|
|
46
|
+
|
|
47
|
+
EXPOSE 3000
|
|
48
|
+
STOPSIGNAL SIGTERM
|
|
49
|
+
CMD ["bun", "apps/web/server.js"]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { createKnipConfig } from "@nexst/lint/knip";
|
|
2
|
+
import type { KnipConfig } from "knip";
|
|
3
|
+
|
|
4
|
+
const config: KnipConfig = createKnipConfig({
|
|
5
|
+
entry: [
|
|
6
|
+
// API clients are template infrastructure: keep them until the first query uses them.
|
|
7
|
+
"src/lib/graphql-client.ts",
|
|
8
|
+
"src/lib/graphql-server.ts",
|
|
9
|
+
// The UI kit is a library: components stay exported until the app uses them.
|
|
10
|
+
"src/components/**/*.tsx", // @if ui
|
|
11
|
+
],
|
|
12
|
+
project: ["src/**/*.{css,mdx}"],
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export default config;
|