@lenne.tech/nest-server 11.24.1 → 11.24.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/.claude/rules/configurable-features.md +1 -0
  2. package/.claude/rules/package-management.md +61 -2
  3. package/CLAUDE.md +77 -0
  4. package/FRAMEWORK-API.md +1 -1
  5. package/dist/core/common/decorators/unified-field.decorator.d.ts +2 -1
  6. package/dist/core/common/decorators/unified-field.decorator.js +60 -13
  7. package/dist/core/common/decorators/unified-field.decorator.js.map +1 -1
  8. package/dist/core/common/helpers/register-enum.helper.d.ts +6 -0
  9. package/dist/core/common/helpers/register-enum.helper.js +23 -1
  10. package/dist/core/common/helpers/register-enum.helper.js.map +1 -1
  11. package/dist/core/common/inputs/combined-filter.input.js +1 -1
  12. package/dist/core/common/inputs/combined-filter.input.js.map +1 -1
  13. package/dist/core/common/inputs/single-filter.input.js +1 -1
  14. package/dist/core/common/inputs/single-filter.input.js.map +1 -1
  15. package/dist/core/common/inputs/sort.input.js +1 -1
  16. package/dist/core/common/inputs/sort.input.js.map +1 -1
  17. package/dist/core/common/interfaces/server-options.interface.d.ts +7 -1
  18. package/dist/core/common/services/email.service.d.ts +2 -2
  19. package/dist/core/common/services/email.service.js +7 -0
  20. package/dist/core/common/services/email.service.js.map +1 -1
  21. package/dist/tsconfig.build.tsbuildinfo +1 -1
  22. package/migration-guides/11.24.2-to-11.24.3.md +255 -0
  23. package/package.json +32 -13
  24. package/src/core/common/decorators/unified-field.decorator.ts +173 -23
  25. package/src/core/common/helpers/register-enum.helper.ts +89 -1
  26. package/src/core/common/inputs/combined-filter.input.ts +1 -1
  27. package/src/core/common/inputs/single-filter.input.ts +1 -1
  28. package/src/core/common/inputs/sort.input.ts +1 -1
  29. package/src/core/common/interfaces/server-options.interface.ts +34 -2
  30. package/src/core/common/services/email.service.ts +17 -3
@@ -0,0 +1,255 @@
1
+ # Migration Guide: 11.24.2 → 11.24.3
2
+
3
+ ## Overview
4
+
5
+ | Category | Details |
6
+ |----------|---------|
7
+ | **Breaking Changes** | None |
8
+ | **New Features** | Simplified enum API, `registerEnums()`, expanded mail transports |
9
+ | **Bugfixes** | Optional enum fields no longer rejected as "must be an object" |
10
+ | **Deprecations** | `enum: { enum: MyEnum, enumName: ... }` long form (still works) |
11
+ | **Migration Effort** | ~5 min setup + find-and-replace per project |
12
+
13
+ ---
14
+
15
+ ## Quick Start (Minimum — No Code Changes Required)
16
+
17
+ ```bash
18
+ pnpm install @lenne.tech/nest-server@11.24.3
19
+ pnpm run build
20
+ pnpm test
21
+ ```
22
+
23
+ Everything works as before. You will see **deprecation warnings** for every
24
+ `enum: { enum: ... }` usage at startup. These are informational — no behavior
25
+ changes, no errors. Follow the steps below to migrate and silence them.
26
+
27
+ ---
28
+
29
+ ## What Changed
30
+
31
+ ### Bugfix: Optional Enum Validation
32
+
33
+ Fields like `status?: MyEnum` were rejected with `"must be an object"`.
34
+ Fixed — `IsEnum()` is now the authoritative validator for enum fields.
35
+
36
+ ### New: Simplified Enum API
37
+
38
+ The `{ enum: MyEnum }` wrapper is no longer needed:
39
+
40
+ ```typescript
41
+ // OLD (deprecated — still works, emits warning)
42
+ @UnifiedField({ enum: { enum: StatusEnum, enumName: 'StatusEnum' } })
43
+
44
+ // NEW (recommended)
45
+ @UnifiedField({ enum: StatusEnum })
46
+ ```
47
+
48
+ ### New: `registerEnums()` Bulk Registration
49
+
50
+ One-line setup per project — enables enum name auto-detection so you can
51
+ drop `enumName` entirely:
52
+
53
+ ```typescript
54
+ import * as Enums from './common/enums';
55
+ import { registerEnums } from '@lenne.tech/nest-server';
56
+ registerEnums(Enums);
57
+ ```
58
+
59
+ ### New: `isArray` Auto-Propagation
60
+
61
+ Array enum fields no longer need `options: { each: true }`:
62
+
63
+ ```typescript
64
+ // OLD
65
+ @UnifiedField({ enum: { enum: X, options: { each: true } }, isArray: true, type: () => X })
66
+
67
+ // NEW
68
+ @UnifiedField({ enum: X, isArray: true, type: () => X })
69
+ ```
70
+
71
+ ### New: Expanded Mail Transports
72
+
73
+ `email.smtp` now accepts all nodemailer transports. `{ jsonTransport: true }`
74
+ is useful for CI/e2e tests (no SMTP server needed). A runtime guard prevents
75
+ accidental use in production/staging.
76
+
77
+ ---
78
+
79
+ ## Migration Steps
80
+
81
+ ### Step 1: Set Up `registerEnums()` (1 minute)
82
+
83
+ This enables auto-detection of enum names from barrel exports. After this
84
+ step, `enumName` is no longer needed.
85
+
86
+ **Prerequisite:** Your project has a barrel file that re-exports all enums
87
+ (e.g. `src/server/common/enums/index.ts`). If not, create one.
88
+
89
+ ```typescript
90
+ // src/server/common/enums/index.ts
91
+ export { ContactStatusEnum } from './contact-status.enum';
92
+ export { IndustryEnum } from './industry.enum';
93
+ // ... all your enums
94
+ ```
95
+
96
+ Then add one line to your module setup:
97
+
98
+ ```typescript
99
+ // src/server/server.module.ts (or main.ts, before NestFactory.create)
100
+ import * as Enums from './common/enums';
101
+ import { registerEnums } from '@lenne.tech/nest-server';
102
+
103
+ registerEnums(Enums);
104
+ ```
105
+
106
+ **How it works:** `import * as Enums` creates an object where the keys are
107
+ the export names (`{ ContactStatusEnum: {...}, IndustryEnum: {...} }`).
108
+ `registerEnums()` iterates the entries, filters out non-enums, and registers
109
+ each enum for both Swagger and GraphQL under its export name.
110
+
111
+ **Verify:** Start your server — enum names should appear correctly in
112
+ Swagger UI and GraphQL schema. If you see missing enum names, check that
113
+ the enum is exported from the barrel file.
114
+
115
+ ### Step 2: Transform Enum Declarations (find-and-replace)
116
+
117
+ Use these regex patterns to transform all `@UnifiedField` enum usages.
118
+ Run them in order.
119
+
120
+ **Pattern A — Remove `options: { each: true }` from array enums:**
121
+
122
+ ```
123
+ Search: enum: \{ enum: (\w+),\s*enumName: '[^']+',\s*options: \{ each: true \}\s*\}
124
+ Replace: enum: $1
125
+ ```
126
+
127
+ **Pattern B — Long form with enumName → shortcut:**
128
+
129
+ ```
130
+ Search: enum: \{ enum: (\w+),\s*enumName: '[^']+'\s*\}
131
+ Replace: enum: $1
132
+ ```
133
+
134
+ **Pattern C — Long form without enumName → shortcut:**
135
+
136
+ ```
137
+ Search: enum: \{ enum: (\w+)\s*\}
138
+ Replace: enum: $1
139
+ ```
140
+
141
+ **After each pattern:** Build and run tests to verify nothing broke:
142
+
143
+ ```bash
144
+ pnpm run build && pnpm test
145
+ ```
146
+
147
+ ### Step 3: Verify (1 minute)
148
+
149
+ ```bash
150
+ # Check no long-form patterns remain
151
+ grep -rn "enum: { enum:" src/ --include="*.ts"
152
+
153
+ # Start server — should have zero deprecation warnings
154
+ pnpm start
155
+ ```
156
+
157
+ ---
158
+
159
+ ## Cheat Sheet
160
+
161
+ | Before (deprecated) | After |
162
+ |---------------------|-------|
163
+ | `enum: { enum: X }` | `enum: X` |
164
+ | `enum: { enum: X, enumName: 'X' }` | `enum: X` |
165
+ | `enum: { enum: X, options: { each: true } }, isArray: true` | `enum: X, isArray: true` |
166
+ | `enum: { enum: X, enumName: 'X', options: { each: true } }, isArray: true` | `enum: X, isArray: true` |
167
+
168
+ For cases where you need explicit control:
169
+
170
+ | Need | Solution |
171
+ |------|----------|
172
+ | Custom enum name in schema | `enum: X, enumName: 'CustomName'` |
173
+ | Disable auto-detection | `enum: X, enumName: null` |
174
+ | Skip all validation | `enum: X, isAny: true` |
175
+ | Custom validator | `enum: X, validator: (opts) => [...]` |
176
+
177
+ ---
178
+
179
+ ## Compatibility Notes
180
+
181
+ ### Deprecation Warnings
182
+
183
+ After updating, every `enum: { enum: ... }` usage emits a `console.warn`
184
+ at class definition time (server startup). This is intentional — it helps
185
+ you find all places that need migration. The warnings disappear once you
186
+ complete Steps 1–3.
187
+
188
+ If you need to update the package but cannot migrate immediately, the
189
+ warnings are purely informational — no behavior changes.
190
+
191
+ ### Existing `options: { each: true }`
192
+
193
+ Explicit `options: { each: true }` continues to work. The auto-propagation
194
+ from `isArray` only applies when `each` is not already set. You can remove
195
+ the explicit option at your own pace.
196
+
197
+ ### Custom `isAny` / `validator` Workarounds
198
+
199
+ If you added `isAny: true` or a custom `validator` to work around the
200
+ optional-enum bug, both continue to work. You can safely remove them.
201
+
202
+ ### SMTP Configuration
203
+
204
+ All existing `email.smtp` configurations continue to work unchanged. The
205
+ type is a strict superset of the previous type.
206
+
207
+ ---
208
+
209
+ ## Troubleshooting
210
+
211
+ ### "JSONTransport is not permitted in production/staging environments"
212
+
213
+ This new runtime guard triggers when `email.smtp` is `{ jsonTransport: true }`
214
+ in `NODE_ENV=production` or `NODE_ENV=staging`. JSONTransport silently
215
+ discards all mail — use a real transport in production.
216
+
217
+ ### Enum name missing in Swagger/GraphQL after migration
218
+
219
+ Ensure the enum is exported from your barrel file and `registerEnums()` is
220
+ called before NestJS bootstraps. Check with:
221
+
222
+ ```typescript
223
+ import { enumNameRegistry } from '@lenne.tech/nest-server';
224
+ console.log(enumNameRegistry.get(MyEnum)); // Should print the enum name
225
+ ```
226
+
227
+ ### Optional enum fields still rejected
228
+
229
+ Verify: (1) `enum:` is set on the field, (2) the field type is the enum
230
+ type, (3) you're running 11.24.3+ (`pnpm list @lenne.tech/nest-server`).
231
+
232
+ ---
233
+
234
+ ## Module Documentation
235
+
236
+ | Module / File | Documentation |
237
+ |---------------|---------------|
238
+ | `@UnifiedField` decorator | [`src/core/common/decorators/unified-field.decorator.ts`](../src/core/common/decorators/unified-field.decorator.ts) — JSDoc on `UnifiedFieldOptions.enum` and `enumName` |
239
+ | `registerEnum` / `registerEnums` | [`src/core/common/helpers/register-enum.helper.ts`](../src/core/common/helpers/register-enum.helper.ts) — JSDoc with `@example` blocks |
240
+ | `MailTransportOptions` type | [`src/core/common/interfaces/server-options.interface.ts`](../src/core/common/interfaces/server-options.interface.ts) — all nodemailer transport types |
241
+ | `FRAMEWORK-API.md` | [`FRAMEWORK-API.md`](../FRAMEWORK-API.md) — auto-generated API reference (v11.24.3) |
242
+
243
+ ### New Public Exports (via `src/index.ts`)
244
+
245
+ - `registerEnums(namespace, options?)` — bulk-register all enums from a barrel-export namespace
246
+ - `graphqlEnumRegistry` — WeakSet tracking GraphQL-registered enums (advanced use only)
247
+ - `MailTransportOptions` — union type of all nodemailer transport configurations
248
+ - `RegisterEnumsOptions` — options interface for `registerEnums()`
249
+
250
+ ---
251
+
252
+ ## References
253
+
254
+ - [nest-server-starter](https://github.com/lenneTech/nest-server-starter)
255
+ - [nodemailer transports](https://nodemailer.com/transports/)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lenne.tech/nest-server",
3
- "version": "11.24.1",
3
+ "version": "11.24.3",
4
4
  "description": "Modern, fast, powerful Node.js web framework in TypeScript based on Nest with a GraphQL API and a connection to MongoDB (or other databases).",
5
5
  "keywords": [
6
6
  "node",
@@ -15,7 +15,7 @@
15
15
  "license": "MIT",
16
16
  "scripts": {
17
17
  "build": "rimraf dist && nest build && pnpm run build:copy-types && pnpm run build:copy-templates && pnpm run build:add-type-references && pnpm run build:framework-api",
18
- "build:framework-api": "npx tsx scripts/generate-framework-api.ts",
18
+ "build:framework-api": "tsx scripts/generate-framework-api.ts",
19
19
  "build:copy-types": "mkdir -p dist/types && cp src/types/*.d.ts dist/types/",
20
20
  "build:copy-templates": "mkdir -p dist/core/modules/migrate/templates && cp src/core/modules/migrate/templates/migration-project.template.ts dist/core/modules/migrate/templates/",
21
21
  "build:add-type-references": "node scripts/add-type-references.js",
@@ -78,16 +78,16 @@
78
78
  "@as-integrations/express5": "1.1.2",
79
79
  "@better-auth/passkey": "1.5.5",
80
80
  "@getbrevo/brevo": "3.0.1",
81
- "@nestjs/apollo": "13.2.4",
81
+ "@nestjs/apollo": "13.2.5",
82
82
  "@nestjs/common": "11.1.18",
83
83
  "@nestjs/core": "11.1.18",
84
- "@nestjs/graphql": "13.2.4",
84
+ "@nestjs/graphql": "13.2.5",
85
85
  "@nestjs/jwt": "11.0.2",
86
86
  "@nestjs/mongoose": "11.0.4",
87
87
  "@nestjs/passport": "11.0.5",
88
88
  "@nestjs/platform-express": "11.1.18",
89
89
  "@nestjs/schedule": "6.1.1",
90
- "@nestjs/swagger": "11.2.6",
90
+ "@nestjs/swagger": "11.2.7",
91
91
  "@nestjs/terminus": "11.1.1",
92
92
  "@nestjs/websockets": "11.1.18",
93
93
  "@tus/file-store": "2.0.0",
@@ -125,7 +125,7 @@
125
125
  },
126
126
  "devDependencies": {
127
127
  "@compodoc/compodoc": "1.2.1",
128
- "@nestjs/cli": "11.0.18",
128
+ "@nestjs/cli": "11.0.19",
129
129
  "@nestjs/schematics": "11.0.10",
130
130
  "@nestjs/testing": "11.1.18",
131
131
  "@swc/cli": "0.8.1",
@@ -136,11 +136,11 @@
136
136
  "@types/express": "5.0.6",
137
137
  "@types/lodash": "4.17.24",
138
138
  "@types/multer": "2.1.0",
139
- "@types/node": "25.5.2",
139
+ "@types/node": "25.6.0",
140
140
  "@types/nodemailer": "8.0.0",
141
141
  "@types/passport": "1.0.17",
142
- "@vitest/coverage-v8": "4.1.3",
143
- "@vitest/ui": "4.1.3",
142
+ "@vitest/coverage-v8": "4.1.4",
143
+ "@vitest/ui": "4.1.4",
144
144
  "ansi-colors": "4.1.3",
145
145
  "find-file-up": "2.0.1",
146
146
  "husky": "9.1.7",
@@ -152,13 +152,13 @@
152
152
  "rimraf": "6.1.3",
153
153
  "ts-node": "10.9.2",
154
154
  "tsconfig-paths": "4.2.0",
155
+ "tsx": "4.21.0",
155
156
  "tus-js-client": "4.3.1",
156
157
  "typescript": "5.9.3",
157
158
  "unplugin-swc": "1.5.9",
158
159
  "vite": "7.3.2",
159
160
  "vite-plugin-node": "7.0.0",
160
- "vite-tsconfig-paths": "6.1.1",
161
- "vitest": "4.1.3"
161
+ "vitest": "4.1.4"
162
162
  },
163
163
  "main": "dist/index.js",
164
164
  "types": "dist/index.d.ts",
@@ -181,7 +181,27 @@
181
181
  },
182
182
  "packageManager": "pnpm@10.33.0",
183
183
  "pnpm": {
184
+ "//overrides": {
185
+ "axios@<1.15.0": "Security: SSRF via NO_PROXY bypass (GHSA-3p68-rc4w-qgx5) and unrestricted cloud metadata exfiltration (GHSA-fvcv-3m26-pcqx) - transitive via @getbrevo/brevo and node-mailjet. Remove when @getbrevo/brevo ships axios>=1.15.0",
186
+ "minimatch@<3.1.5": "Security: RegExp DoS (GHSA-p8p7-x288-28g6) - transitive via @getbrevo/brevo>rewire>eslint",
187
+ "minimatch@>=9.0.0 <9.0.9": "Security: RegExp DoS - transitive via @nestjs/cli>@swc/cli",
188
+ "minimatch@>=10.0.0 <10.2.5": "Security: RegExp DoS - transitive via @nestjs/apollo>ts-morph>@ts-morph/common and nodemon",
189
+ "ajv@<6.14.0": "Security: prototype pollution - transitive via @getbrevo/brevo>rewire>eslint",
190
+ "ajv@>=7.0.0-alpha.0 <8.18.0": "Security: prototype pollution - transitive via @nestjs/cli>@angular-devkit",
191
+ "undici@>=7.0.0 <7.24.7": "Security: various CVEs - transitive via @compodoc/compodoc>cheerio",
192
+ "srvx@<0.11.15": "Compatibility: @tus/server@2.3.0 requires ~0.8.2 but 0.11.15 needed for security - remove when @tus/server ships with >=0.11.15",
193
+ "handlebars@>=4.0.0 <4.7.9": "Security: prototype pollution (GHSA-q42p-pg8m-cqh6) - transitive via @compodoc/compodoc",
194
+ "brace-expansion@<1.1.13": "Security: RegExp DoS - transitive via eslint>minimatch",
195
+ "brace-expansion@>=4.0.0 <5.0.5": "Security: RegExp DoS - transitive via nodemon>minimatch and @ts-morph/common>minimatch",
196
+ "picomatch@<2.3.2": "Security: ReDoS - transitive via @nestjs/graphql>fast-glob>micromatch and @compodoc/compodoc>chokidar",
197
+ "picomatch@>=4.0.0 <4.0.4": "Security: ReDoS - transitive via vitest and vite",
198
+ "path-to-regexp@>=8.0.0 <8.4.2": "Security: ReDoS (GHSA-rhx6-c78j-4q9w) - transitive via express>router",
199
+ "kysely@>=0.26.0 <0.28.15": "Security: SQL injection - transitive via better-auth",
200
+ "lodash@>=4.0.0 <4.18.0": "Security: CVE in lodash@4.17.x - transitive via @nestjs/graphql. 4.18.1 is the latest patched version",
201
+ "defu@<=6.1.6": "Security: prototype pollution via __proto__ key - transitive via better-auth"
202
+ },
184
203
  "overrides": {
204
+ "axios@<1.15.0": "1.15.0",
185
205
  "minimatch@<3.1.5": "3.1.5",
186
206
  "minimatch@>=9.0.0 <9.0.9": "9.0.9",
187
207
  "minimatch@>=10.0.0 <10.2.5": "10.2.5",
@@ -197,8 +217,7 @@
197
217
  "path-to-regexp@>=8.0.0 <8.4.2": "8.4.2",
198
218
  "kysely@>=0.26.0 <0.28.15": "0.28.15",
199
219
  "lodash@>=4.0.0 <4.18.0": "4.18.1",
200
- "defu@<=6.1.6": "6.1.7",
201
- "vite@>=7.0.0 <7.3.2": "7.3.2"
220
+ "defu@<=6.1.6": "6.1.7"
202
221
  },
203
222
  "onlyBuiltDependencies": [
204
223
  "bcrypt",
@@ -117,8 +117,47 @@ export interface UnifiedFieldOptions {
117
117
  * - undefined (default): Normal @UnifiedField behavior
118
118
  */
119
119
  exclude?: boolean;
120
- /** Enum for class-validator */
121
- enum?: { enum: EnumAllowedTypes; enumName?: string; options?: ValidationOptions };
120
+ /**
121
+ * Enum for class-validator.
122
+ *
123
+ * **Recommended form:**
124
+ * ```typescript
125
+ * @UnifiedField({ enum: MyEnum })
126
+ * @UnifiedField({ enum: MyEnum, enumName: 'MyEnum' })
127
+ * ```
128
+ *
129
+ * **Deprecated long form** (still works, emits a deprecation warning):
130
+ * ```typescript
131
+ * @UnifiedField({ enum: { enum: MyEnum, enumName: 'MyEnum' } })
132
+ * ```
133
+ * The long form will be removed in a future MINOR version. Use the shortcut
134
+ * form with the top-level `enumName` property instead.
135
+ *
136
+ * Why not auto-detect from the property type? TypeScript's `emitDecoratorMetadata`
137
+ * reflects enum types as their base primitive in `design:type` — required string
138
+ * enums become `String`, numeric enums become `Number`. Optional enum fields
139
+ * (`status?: MyEnum`) are reflected as `Object` because TS sees the union
140
+ * `MyEnum | undefined`. In all cases the original enum object reference is lost
141
+ * at compile time, so the decorator cannot recover it without an explicit
142
+ * `enum:` option.
143
+ */
144
+ enum?: EnumAllowedTypes | { enum: EnumAllowedTypes; enumName?: string; options?: ValidationOptions };
145
+ /**
146
+ * Explicit name for the enum in Swagger/GraphQL schema.
147
+ *
148
+ * When set, this name is used in the generated API schema. When omitted,
149
+ * the decorator auto-detects the name via `registerEnum()`, GraphQL metadata,
150
+ * or the enum's runtime name.
151
+ *
152
+ * Set to `null` to explicitly disable auto-detection (swagger receives `undefined`).
153
+ *
154
+ * **Note:** In the deprecated long-form, `enum: { enum: X, enumName: null }` passes
155
+ * `null` directly to swagger (not `undefined`). The top-level `enumName: null` normalizes
156
+ * to `undefined` for consistency. Both forms disable auto-detection effectively.
157
+ *
158
+ * Takes precedence over `enumName` inside a long-form `enum: { ... }` object.
159
+ */
160
+ enumName?: string | null;
122
161
  /** Example value for swagger api documentation */
123
162
  example?: any;
124
163
  /** Options for graphql */
@@ -161,10 +200,99 @@ export interface UnifiedFieldOptions {
161
200
  validator?: (opts: ValidationOptions) => PropertyDecorator[];
162
201
  }
163
202
 
203
+ type NormalizedEnumOptions = { enum: EnumAllowedTypes; enumName?: string; options?: ValidationOptions };
204
+
205
+ /**
206
+ * Detect whether `value` is the long-form `{ enum: MyEnum, ... }` options
207
+ * object. If it is not, we treat `value` itself as the enum (shortcut form).
208
+ *
209
+ * Discrimination strategy (three layers):
210
+ *
211
+ * 1. **Own `enum` key** — a plain TS keyword enum (`enum Foo { ... }`) never
212
+ * has a member named `enum` (reserved keyword). Const-object enums CAN,
213
+ * but it's rare.
214
+ *
215
+ * 2. **Inner value is an object** — in the long form, `value.enum` holds an
216
+ * enum object (typeof 'object'). A const-object enum member named `enum`
217
+ * with a primitive value (string / number) is ruled out here.
218
+ *
219
+ * 3. **Inner value looks like an enum** — all of the inner object's own
220
+ * values must be strings or numbers. This guards against the theoretical
221
+ * case where a const-object enum has `enum: { nested: true }` as a member
222
+ * — that inner object's values are booleans, so it fails this check and
223
+ * the outer object is correctly treated as the shortcut form.
224
+ */
225
+ function isEnumOptionsObject(value: unknown): value is NormalizedEnumOptions {
226
+ if (typeof value !== 'object' || value === null) return false;
227
+ if (!Object.prototype.hasOwnProperty.call(value, 'enum')) return false;
228
+ const inner = (value as Record<string, unknown>).enum;
229
+ if (typeof inner !== 'object' || inner === null) return false;
230
+ // A valid enum object contains only string or number values (enum members).
231
+ // If the inner object has non-primitive values it cannot be an enum, so the
232
+ // outer object is the enum itself (shortcut form with a member named 'enum').
233
+ const vals = Object.values(inner as Record<string, unknown>);
234
+ return vals.length > 0 && vals.every((v) => typeof v === 'string' || typeof v === 'number');
235
+ }
236
+
237
+ /**
238
+ * Detect likely misconfiguration: an object with `enumName` or `options` keys
239
+ * but no `enum` key. This pattern looks like a long-form options object where
240
+ * the required `enum` property was forgotten. Without this guard the object
241
+ * would be silently treated as the enum itself (shortcut form), leading to
242
+ * incorrect validation (e.g. only accepting the string "Status" instead of
243
+ * actual enum members).
244
+ */
245
+ function warnIfLikelyMisconfiguredEnumOptions(value: unknown, propertyHint: string): void {
246
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return;
247
+ const obj = value as Record<string, unknown>;
248
+ if (
249
+ !Object.prototype.hasOwnProperty.call(obj, 'enum') &&
250
+ (Object.prototype.hasOwnProperty.call(obj, 'enumName') || Object.prototype.hasOwnProperty.call(obj, 'options'))
251
+ ) {
252
+ // eslint-disable-next-line no-console
253
+ console.warn(
254
+ `[@UnifiedField] Probable misconfiguration on "${propertyHint}": the enum option has ` +
255
+ `"enumName" or "options" but no "enum" key. Did you mean { enum: MyEnum, enumName: ... }? ` +
256
+ `The object will be treated as the enum itself (shortcut form), which is likely wrong.`,
257
+ );
258
+ }
259
+ }
260
+
164
261
  export function UnifiedField(opts: UnifiedFieldOptions = {}): PropertyDecorator {
262
+ // Normalize the shortcut form `enum: MyEnum` into the long form
263
+ // `enum: { enum: MyEnum }` so the rest of the decorator can assume a uniform
264
+ // object shape. Single `isEnumOptionsObject` call, result reused for both variables.
265
+ const isLongForm = opts.enum ? isEnumOptionsObject(opts.enum) : false;
266
+ const normalizedEnum: NormalizedEnumOptions | undefined = opts.enum
267
+ ? isLongForm
268
+ ? (opts.enum as NormalizedEnumOptions)
269
+ : { enum: opts.enum as EnumAllowedTypes }
270
+ : undefined;
271
+
272
+ // The original long-form object (if provided) — needed to distinguish
273
+ // `enum: { enum: MyEnum, enumName: undefined }` (explicit disable) from
274
+ // `enum: MyEnum` (shortcut: auto-detect).
275
+ const originalEnumOpts = isLongForm ? (opts.enum as NormalizedEnumOptions) : undefined;
276
+
165
277
  return (target: any, propertyKey: string | symbol) => {
166
278
  const key = String(propertyKey);
167
279
 
280
+ // Warn if the enum option looks like a long-form object with a missing `enum` key
281
+ if (opts.enum && !originalEnumOpts) {
282
+ warnIfLikelyMisconfiguredEnumOptions(opts.enum, `${target.constructor?.name || '?'}.${key}`);
283
+ }
284
+
285
+ // Deprecation warning for long-form enum: { enum: MyEnum, ... }
286
+ if (originalEnumOpts) {
287
+ // eslint-disable-next-line no-console
288
+ console.warn(
289
+ `[@UnifiedField] Deprecated long-form enum on "${target.constructor?.name || '?'}.${key}": ` +
290
+ `enum: { enum: ... } is deprecated. Use enum: MyEnum` +
291
+ (originalEnumOpts.enumName ? `, enumName: '${originalEnumOpts.enumName}'` : '') +
292
+ ` instead. The long-form will be removed in a future version.`,
293
+ );
294
+ }
295
+
168
296
  if (opts.exclude === true) {
169
297
  // ── EXPLICIT EXCLUSION ──
170
298
  const excludedKeys: string[] = Reflect.getOwnMetadata(EXCLUDED_FIELD_KEYS, target) || [];
@@ -282,7 +410,7 @@ export function UnifiedField(opts: UnifiedFieldOptions = {}): PropertyDecorator
282
410
 
283
411
  // Set type for swagger
284
412
  if (baseType) {
285
- if (opts.enum) {
413
+ if (normalizedEnum) {
286
414
  swaggerOpts.type = () => String;
287
415
  } else {
288
416
  swaggerOpts.type = baseType;
@@ -300,28 +428,34 @@ export function UnifiedField(opts: UnifiedFieldOptions = {}): PropertyDecorator
300
428
  }
301
429
 
302
430
  // Set enum options
303
- if (opts.enum && opts.enum.enum) {
304
- swaggerOpts.enum = opts.enum.enum;
305
-
306
- // Set enumName with auto-detection:
307
- // - If enumName property doesn't exist at all, auto-detect the name
308
- // - If enumName is explicitly set (even to null/undefined), use that value
309
- // This allows explicit opts.enum.enumName = undefined to disable auto-detection
310
- if (!('enumName' in opts.enum)) {
311
- // Property doesn't exist, try auto-detection
312
- const autoDetectedName = getEnumName(opts.enum.enum);
431
+ if (normalizedEnum && normalizedEnum.enum) {
432
+ swaggerOpts.enum = normalizedEnum.enum;
433
+
434
+ // Resolve enumName with priority chain:
435
+ // 1. Top-level opts.enumName (new recommended form) highest priority
436
+ // - string use it
437
+ // - null explicitly disable auto-detection
438
+ // - undefined fall through to next level
439
+ // 2. Long-form originalEnumOpts.enumName (deprecated) — backwards compat
440
+ // - present (even if undefined/null) → use it (preserves old semantics)
441
+ // - absent → fall through
442
+ // 3. Auto-detection via registerEnum / GraphQL metadata / runtime name
443
+ if ('enumName' in opts && opts.enumName !== undefined) {
444
+ // Top-level enumName: string → use, null → disable auto-detection
445
+ swaggerOpts.enumName = opts.enumName ?? undefined;
446
+ } else if (originalEnumOpts && 'enumName' in originalEnumOpts) {
447
+ // Deprecated long-form enumName
448
+ swaggerOpts.enumName = originalEnumOpts.enumName;
449
+ } else {
450
+ // Auto-detection
451
+ const autoDetectedName = getEnumName(normalizedEnum.enum);
313
452
  if (autoDetectedName) {
314
453
  swaggerOpts.enumName = autoDetectedName;
315
454
  }
316
- } else {
317
- // Property exists (even if undefined/null), use its value
318
- swaggerOpts.enumName = opts.enum.enumName;
319
455
  }
320
-
321
- IsEnum(opts.enum.enum, opts.enum.options)(target, propertyKey);
322
456
  }
323
457
 
324
- // Array handling
458
+ // Array handling — must run BEFORE IsEnum so `each` is resolved
325
459
  if (isArrayField) {
326
460
  swaggerOpts.isArray = true;
327
461
  IsArray(valOpts)(target, propertyKey);
@@ -331,16 +465,27 @@ export function UnifiedField(opts: UnifiedFieldOptions = {}): PropertyDecorator
331
465
  valOpts.each = false;
332
466
  }
333
467
 
468
+ // Apply IsEnum after array handling so `each` from isArray is available.
469
+ // Merge isArray's `each` into the enum options automatically — the user
470
+ // no longer needs to pass `options: { each: true }` redundantly.
471
+ if (normalizedEnum && normalizedEnum.enum && !opts.isAny && !opts.validator) {
472
+ const enumValOpts: ValidationOptions = { ...normalizedEnum.options };
473
+ if (isArrayField && enumValOpts.each === undefined) {
474
+ enumValOpts.each = true;
475
+ }
476
+ IsEnum(normalizedEnum.enum, enumValOpts)(target, propertyKey);
477
+ }
478
+
334
479
  // Type function for gql
335
480
  // We need to keep the factory pattern (calling resolvedTypeFn inside the arrow function)
336
481
  // to support circular references. But we also need to extract array item types to avoid double-nesting.
337
482
  const gqlTypeFn = isArrayField
338
483
  ? () => {
339
- const resolved = opts.enum?.enum || opts.gqlType || resolvedTypeFn();
484
+ const resolved = normalizedEnum?.enum || opts.gqlType || resolvedTypeFn();
340
485
  // Extract item type if user provided [ItemType] syntax to avoid [[ItemType]]
341
486
  return [Array.isArray(resolved) && resolved.length === 1 ? resolved[0] : resolved];
342
487
  }
343
- : () => opts.enum?.enum || opts.gqlType || resolvedTypeFn();
488
+ : () => normalizedEnum?.enum || opts.gqlType || resolvedTypeFn();
344
489
 
345
490
  // Gql decorator
346
491
  Field(gqlTypeFn, gqlOpts)(target, propertyKey);
@@ -356,7 +501,12 @@ export function UnifiedField(opts: UnifiedFieldOptions = {}): PropertyDecorator
356
501
  // Completely skip validation if its any
357
502
  if (opts.validator) {
358
503
  opts.validator(valOpts).forEach((d) => d(target, propertyKey));
359
- } else if (!opts.isAny) {
504
+ } else if (!opts.isAny && !normalizedEnum) {
505
+ // Skip the built-in validator when an enum is set: IsEnum has already been
506
+ // applied above and is the authoritative validator for enum values. Without this
507
+ // guard, fields declared as `foo?: MyEnum` emit `design:type = Object` (because
508
+ // `MyEnum | undefined` is not primitive), which maps to IsObject() here and
509
+ // rejects perfectly valid enum string values with "foo must be an object".
360
510
  const validator = getBuiltInValidator(baseType, valOpts, isArrayField, target);
361
511
  if (validator) {
362
512
  validator(target, propertyKey);
@@ -370,7 +520,7 @@ export function UnifiedField(opts: UnifiedFieldOptions = {}): PropertyDecorator
370
520
  Type(() => Date)(target, propertyKey);
371
521
  }
372
522
  // Check if it's a primitive, if not apply transform
373
- else if (!isPrimitive(baseType) && !opts.enum && !isGraphQLScalar(baseType)) {
523
+ else if (!isPrimitive(baseType) && !normalizedEnum && !isGraphQLScalar(baseType)) {
374
524
  Type(() => baseType)(target, propertyKey);
375
525
  ValidateNested({ each: isArrayField })(target, propertyKey);
376
526