@fiado/type-kit 3.419.0 → 3.421.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.
@@ -0,0 +1,43 @@
1
+ import { IsTemplatePlaceholders } from '../../../src/messagesConnector/validators/IsTemplatePlaceholders';
2
+
3
+ const validador = new IsTemplatePlaceholders();
4
+ const acepta = (texto: unknown): boolean => validador.validate(texto);
5
+
6
+ describe('IsTemplatePlaceholders', () => {
7
+ describe('acepta las dos formas que el renderizador sustituye', () => {
8
+ it.each([
9
+ ['sin placeholders', 'Hola, tu credito esta al corriente'],
10
+ ['forma simple', 'Hola {cliente_nombre}, tu cuota es {monto}'],
11
+ ['forma doble', 'Hola {{cliente_nombre}}, tu cuota es {{monto}}'],
12
+ ['las dos mezcladas', 'Hola {{cliente_nombre}}, vence el {fecha_venc}'],
13
+ ['nombre con digitos y guion bajo', 'Codigo {var1} y {otra_var2}'],
14
+ ['cadena vacia', ''],
15
+ ])('%s', (_caso, texto) => {
16
+ expect(acepta(texto)).toBe(true);
17
+ });
18
+ });
19
+
20
+ describe('rechaza lo que el renderizador no podria sustituir', () => {
21
+ it.each([
22
+ ['placeholder vacio simple', '{}'],
23
+ ['placeholder vacio doble', '{{}}'],
24
+ ['nombre con espacio', '{var name}'],
25
+ ['nombre con guion', '{var-1}'],
26
+ ['nombre que arranca con digito', '{1var}'],
27
+ ['apertura sin cierre', 'abc {def'],
28
+ ['cierre sin apertura', 'abc } def'],
29
+ ['llave suelta antes de uno valido', '{ {anidado}'],
30
+ ['doble sin cerrar del todo', '{{a}'],
31
+ ['no es string', 42],
32
+ ['nulo', null],
33
+ ])('%s', (_caso, texto) => {
34
+ expect(acepta(texto)).toBe(false);
35
+ });
36
+ });
37
+
38
+ it('el mensaje nombra las dos formas, para que el front sepa cual usar', () => {
39
+ const mensaje = validador.defaultMessage({ property: 'template' } as never);
40
+ expect(mensaje).toContain('{nombre}');
41
+ expect(mensaje).toContain('{{nombre}}');
42
+ });
43
+ });
@@ -0,0 +1,136 @@
1
+ import 'reflect-metadata';
2
+ import { plainToInstance } from 'class-transformer';
3
+ import { validate, ValidationError } from 'class-validator';
4
+ import { MdmNotifyItem } from '../../../src/messagesConnector/dtos/MdmNotifyItem';
5
+ import { MdmNotifyQueueMessageRequest } from '../../../src/messagesConnector/dtos/MdmNotifyQueueMessageRequest';
6
+ import * as MessagesConnectorBarrel from '../../../src/messagesConnector/index';
7
+ import { DeviceNotifyIntensityEnum } from '../../../src/mdm/enums/DeviceNotifyIntensityEnum';
8
+ import { MdmNoticeTypeEnum } from '../../../src/mdm/enums/MdmNoticeTypeEnum';
9
+ import { MdmProviderEnum } from '../../../src/mdm/enums/MdmProviderEnum';
10
+ import { MDM_NOTIFY_MAX_BATCH } from '../../../src/messagesConnector/constants/MdmNotifyBatchLimits';
11
+
12
+ /** ULID real del motor: así genera los creditId `loan-credit-business`. */
13
+ const CREDIT_ID = '01M0B5D7FQA3T06K4REEVPRM33';
14
+
15
+ const validItem = {
16
+ imei: '351756051523999',
17
+ creditId: CREDIT_ID,
18
+ provider: MdmProviderEnum.TRUSTONIC,
19
+ noticeType: MdmNoticeTypeEnum.OVERDUE_1_DAY,
20
+ intensity: DeviceNotifyIntensityEnum.BANNER,
21
+ };
22
+
23
+ /** Lote de `size` equipos con imei distinto: el lote real nunca repite imei. */
24
+ const batchOf = (size: number): Record<string, unknown>[] =>
25
+ Array.from({ length: size }, (_unused, index) => ({ ...validItem, imei: `35175605152${String(index).padStart(4, '0')}` }));
26
+
27
+ const validMessage = {
28
+ items: [validItem],
29
+ operationReference: 'mdm-notify-2026-09-08-0001',
30
+ };
31
+
32
+ /** True si el error de `property` trae ese constraint puntual (no solo "algo falló"). */
33
+ const hasConstraint = (errors: ValidationError[], property: string, constraint: string): boolean =>
34
+ errors.some((e) => e.property === property && Boolean(e.constraints?.[constraint]));
35
+
36
+ const validateMessage = async (payload: Record<string, unknown>): Promise<ValidationError[]> =>
37
+ validate(plainToInstance(MdmNotifyQueueMessageRequest, { ...validMessage, ...payload }));
38
+
39
+ describe('MdmNotifyQueueMessageRequest', () => {
40
+ it('valida el mensaje mínimo de la cola', async () => {
41
+ expect(await validateMessage({})).toEqual([]);
42
+ });
43
+
44
+ it('valida sin tenantId: messages-business asume FIADO', async () => {
45
+ const dto = plainToInstance(MdmNotifyQueueMessageRequest, validMessage);
46
+ expect(await validate(dto)).toEqual([]);
47
+ expect(dto.tenantId).toBeUndefined();
48
+ });
49
+
50
+ it('valida con tenantId explícito', async () => {
51
+ expect(await validateMessage({ tenantId: 'SUREKEEP' })).toEqual([]);
52
+ });
53
+
54
+ it('falla si tenantId viene vacío: ausente cae a FIADO, pero "" no es un tenant', async () => {
55
+ expect(hasConstraint(await validateMessage({ tenantId: '' }), 'tenantId', 'isNotEmpty')).toBe(true);
56
+ });
57
+
58
+ it('falla si el lote viene vacío', async () => {
59
+ expect(hasConstraint(await validateMessage({ items: [] }), 'items', 'arrayNotEmpty')).toBe(true);
60
+ });
61
+
62
+ it('falla si items no es un arreglo', async () => {
63
+ expect(hasConstraint(await validateMessage({ items: 'nada' }), 'items', 'isArray')).toBe(true);
64
+ });
65
+
66
+ it('falla si items no viaja', async () => {
67
+ const { items: _omitted, ...rest } = validMessage;
68
+ const errors = await validate(plainToInstance(MdmNotifyQueueMessageRequest, rest));
69
+ expect(hasConstraint(errors, 'items', 'arrayNotEmpty')).toBe(true);
70
+ });
71
+
72
+ it('falla si dos ítems repiten el mismo imei: el equipo recibiría el aviso dos veces y el acuse colapsa en uno', async () => {
73
+ expect(hasConstraint(await validateMessage({ items: [validItem, validItem] }), 'items', 'arrayUnique')).toBe(true);
74
+ });
75
+
76
+ it('acepta el lote completo hasta el tope', async () => {
77
+ expect(await validateMessage({ items: batchOf(MDM_NOTIFY_MAX_BATCH) })).toEqual([]);
78
+ });
79
+
80
+ it('falla si el lote excede el tope de equipos', async () => {
81
+ const errors = await validateMessage({ items: batchOf(MDM_NOTIFY_MAX_BATCH + 1) });
82
+ expect(hasConstraint(errors, 'items', 'arrayMaxSize')).toBe(true);
83
+ });
84
+
85
+ it('el tope del lote es el mismo que el del endpoint HTTP que reemplaza', () => {
86
+ expect(MDM_NOTIFY_MAX_BATCH).toBe(500);
87
+ });
88
+
89
+ it('falla si operationReference no viaja', async () => {
90
+ const { operationReference: _omitted, ...rest } = validMessage;
91
+ const errors = await validate(plainToInstance(MdmNotifyQueueMessageRequest, rest));
92
+ expect(hasConstraint(errors, 'operationReference', 'isNotEmpty')).toBe(true);
93
+ });
94
+
95
+ it('falla si operationReference viene vacío', async () => {
96
+ expect(hasConstraint(await validateMessage({ operationReference: '' }), 'operationReference', 'isNotEmpty')).toBe(true);
97
+ });
98
+
99
+ it('falla si operationReference excede el largo máximo', async () => {
100
+ const errors = await validateMessage({ operationReference: 'x'.repeat(65) });
101
+ expect(hasConstraint(errors, 'operationReference', 'maxLength')).toBe(true);
102
+ });
103
+
104
+ it('acepta operationReference justo en el largo máximo', async () => {
105
+ expect(await validateMessage({ operationReference: 'x'.repeat(64) })).toEqual([]);
106
+ });
107
+
108
+ it('falla si un ítem trae un provider fuera del enum', async () => {
109
+ const errors = await validateMessage({ items: [{ ...validItem, provider: 'AIRDROID' }] });
110
+ const children = errors.find((e) => e.property === 'items')?.children ?? [];
111
+ expect(hasConstraint(children[0]?.children ?? [], 'provider', 'isEnum')).toBe(true);
112
+ });
113
+
114
+ it('falla si un ítem trae un noticeType fuera del catálogo', async () => {
115
+ const errors = await validateMessage({ items: [{ ...validItem, noticeType: 'OVERDUE_99_DAYS' }] });
116
+ const children = errors.find((e) => e.property === 'items')?.children ?? [];
117
+ expect(hasConstraint(children[0]?.children ?? [], 'noticeType', 'isEnum')).toBe(true);
118
+ });
119
+
120
+ it('falla si un ítem trae un creditId que no es ULID', async () => {
121
+ const errors = await validateMessage({ items: [{ ...validItem, creditId: 'no-es-ulid' }] });
122
+ const children = errors.find((e) => e.property === 'items')?.children ?? [];
123
+ expect(hasConstraint(children[0]?.children ?? [], 'creditId', 'matches')).toBe(true);
124
+ });
125
+
126
+ it('materializa los ítems como MdmNotifyItem', () => {
127
+ const dto = plainToInstance(MdmNotifyQueueMessageRequest, validMessage);
128
+ expect(dto.items[0]).toBeInstanceOf(MdmNotifyItem);
129
+ });
130
+ });
131
+
132
+ describe('barrel de messagesConnector', () => {
133
+ it('exporta el mensaje de cola de avisos MDM', () => {
134
+ expect(MessagesConnectorBarrel.MdmNotifyQueueMessageRequest).toBe(MdmNotifyQueueMessageRequest);
135
+ });
136
+ });
@@ -0,0 +1,16 @@
1
+ import { MdmNotifyItem } from './MdmNotifyItem';
2
+ /**
3
+ * Mensaje que viaja por la cola de messages-business para avisar en lote a los equipos.
4
+ * Reemplaza al `POST /private/messages/mdm-notify`, que se retira.
5
+ */
6
+ export declare class MdmNotifyQueueMessageRequest {
7
+ /**
8
+ * Equipos del lote. El tope es el mismo que el del lote de `loan-credit-business`.
9
+ * El imei repetido duplica el aviso en el equipo y colapsa los dos acuses en uno.
10
+ */
11
+ items: MdmNotifyItem[];
12
+ /** Tenant emisor del aviso. Si se omite, messages-business asume FIADO. */
13
+ tenantId?: string;
14
+ /** Referencia con la que el productor correlaciona este lote. Es la que viaja al proveedor. */
15
+ operationReference: string;
16
+ }
@@ -0,0 +1,47 @@
1
+ "use strict";
2
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
3
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
4
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
5
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
6
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
7
+ };
8
+ var __metadata = (this && this.__metadata) || function (k, v) {
9
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.MdmNotifyQueueMessageRequest = void 0;
13
+ const class_transformer_1 = require("class-transformer");
14
+ const class_validator_1 = require("class-validator");
15
+ const MdmNotifyItem_1 = require("./MdmNotifyItem");
16
+ const MdmNotifyBatchLimits_1 = require("../constants/MdmNotifyBatchLimits");
17
+ /**
18
+ * Mensaje que viaja por la cola de messages-business para avisar en lote a los equipos.
19
+ * Reemplaza al `POST /private/messages/mdm-notify`, que se retira.
20
+ */
21
+ class MdmNotifyQueueMessageRequest {
22
+ }
23
+ exports.MdmNotifyQueueMessageRequest = MdmNotifyQueueMessageRequest;
24
+ __decorate([
25
+ (0, class_transformer_1.Expose)(),
26
+ (0, class_validator_1.IsArray)(),
27
+ (0, class_validator_1.ArrayNotEmpty)(),
28
+ (0, class_validator_1.ArrayMaxSize)(MdmNotifyBatchLimits_1.MDM_NOTIFY_MAX_BATCH),
29
+ (0, class_validator_1.ArrayUnique)((item) => item.imei),
30
+ (0, class_validator_1.ValidateNested)({ each: true }),
31
+ (0, class_transformer_1.Type)(() => MdmNotifyItem_1.MdmNotifyItem),
32
+ __metadata("design:type", Array)
33
+ ], MdmNotifyQueueMessageRequest.prototype, "items", void 0);
34
+ __decorate([
35
+ (0, class_transformer_1.Expose)(),
36
+ (0, class_validator_1.IsOptional)(),
37
+ (0, class_validator_1.IsString)(),
38
+ (0, class_validator_1.IsNotEmpty)(),
39
+ __metadata("design:type", String)
40
+ ], MdmNotifyQueueMessageRequest.prototype, "tenantId", void 0);
41
+ __decorate([
42
+ (0, class_transformer_1.Expose)(),
43
+ (0, class_validator_1.IsString)(),
44
+ (0, class_validator_1.IsNotEmpty)(),
45
+ (0, class_validator_1.MaxLength)(64),
46
+ __metadata("design:type", String)
47
+ ], MdmNotifyQueueMessageRequest.prototype, "operationReference", void 0);
@@ -23,6 +23,7 @@ export * from './dtos/TemplateMessageListFiltersRequest';
23
23
  export * from './constants/MdmNotifyBatchLimits';
24
24
  export * from './dtos/MdmNotifyItem';
25
25
  export * from './dtos/MdmNotifyRequest';
26
+ export * from './dtos/MdmNotifyQueueMessageRequest';
26
27
  export * from './dtos/MdmNotifyResponse';
27
28
  export * from './dtos/MdmNotifyBatchResponse';
28
29
  export * from './constants/MessageIdempotencyLimits';
@@ -39,6 +39,7 @@ __exportStar(require("./dtos/TemplateMessageListFiltersRequest"), exports);
39
39
  __exportStar(require("./constants/MdmNotifyBatchLimits"), exports);
40
40
  __exportStar(require("./dtos/MdmNotifyItem"), exports);
41
41
  __exportStar(require("./dtos/MdmNotifyRequest"), exports);
42
+ __exportStar(require("./dtos/MdmNotifyQueueMessageRequest"), exports);
42
43
  __exportStar(require("./dtos/MdmNotifyResponse"), exports);
43
44
  __exportStar(require("./dtos/MdmNotifyBatchResponse"), exports);
44
45
  __exportStar(require("./constants/MessageIdempotencyLimits"), exports);
@@ -1,10 +1,11 @@
1
1
  import { ValidatorConstraintInterface, ValidationArguments } from 'class-validator';
2
2
  /**
3
- * Valida que un string solo contenga placeholders bien formados `{varName}` donde
4
- * `varName` matchea `^[a-zA-Z][a-zA-Z0-9_]*$`. Rechaza:
3
+ * Valida que un string solo contenga placeholders bien formados, en cualquiera de las dos
4
+ * formas del catalogo: `{varName}` o `{{varName}}`, con `varName` matcheando
5
+ * `^[a-zA-Z][a-zA-Z0-9_]*$`. Rechaza:
5
6
  * - Placeholders con espacios o caracteres invalidos (`{var name}`, `{var-1}`).
6
- * - Llaves sin balancear (`abc {def`, `abc } def`, `{ {nested}` ).
7
- * - Placeholders vacios (`{}`).
7
+ * - Llaves sin balancear (`abc {def`, `abc } def`, `{ {nested}`).
8
+ * - Placeholders vacios (`{}`, `{{}}`).
8
9
  * Acepta texto sin placeholders en absoluto.
9
10
  *
10
11
  * Uso en DTOs: `@Validate(IsTemplatePlaceholders)` sobre un campo string.
@@ -10,11 +10,17 @@ exports.IsTemplatePlaceholders = void 0;
10
10
  const class_validator_1 = require("class-validator");
11
11
  const VALID_PLACEHOLDER = /^[a-zA-Z][a-zA-Z0-9_]*$/;
12
12
  /**
13
- * Valida que un string solo contenga placeholders bien formados `{varName}` donde
14
- * `varName` matchea `^[a-zA-Z][a-zA-Z0-9_]*$`. Rechaza:
13
+ * Las DOS formas que el renderizador sustituye: doble `{{monto}}` y simple `{monto}`.
14
+ * La doble va primero para que no gane el `{monto}` interno, igual que alla.
15
+ */
16
+ const PLACEHOLDER_PATTERN = /\{\{([^{}]*)\}\}|\{([^{}]*)\}/g;
17
+ /**
18
+ * Valida que un string solo contenga placeholders bien formados, en cualquiera de las dos
19
+ * formas del catalogo: `{varName}` o `{{varName}}`, con `varName` matcheando
20
+ * `^[a-zA-Z][a-zA-Z0-9_]*$`. Rechaza:
15
21
  * - Placeholders con espacios o caracteres invalidos (`{var name}`, `{var-1}`).
16
- * - Llaves sin balancear (`abc {def`, `abc } def`, `{ {nested}` ).
17
- * - Placeholders vacios (`{}`).
22
+ * - Llaves sin balancear (`abc {def`, `abc } def`, `{ {nested}`).
23
+ * - Placeholders vacios (`{}`, `{{}}`).
18
24
  * Acepta texto sin placeholders en absoluto.
19
25
  *
20
26
  * Uso en DTOs: `@Validate(IsTemplatePlaceholders)` sobre un campo string.
@@ -23,33 +29,20 @@ let IsTemplatePlaceholders = class IsTemplatePlaceholders {
23
29
  validate(value) {
24
30
  if (typeof value !== 'string')
25
31
  return false;
26
- // Detectar llaves desbalanceadas con un single-pass.
27
- let depth = 0;
28
- for (const ch of value) {
29
- if (ch === '{') {
30
- if (depth > 0)
31
- return false; // anidado no permitido
32
- depth = 1;
33
- }
34
- else if (ch === '}') {
35
- if (depth === 0)
36
- return false; // cierre sin apertura
37
- depth = 0;
38
- }
39
- }
40
- if (depth !== 0)
41
- return false; // apertura sin cierre
42
- // Validar que cada `{...}` matchea el regex.
43
- const matches = value.match(/\{([^}]*)\}/g) ?? [];
44
- for (const raw of matches) {
45
- const inner = raw.slice(1, -1);
46
- if (!VALID_PLACEHOLDER.test(inner))
47
- return false;
48
- }
49
- return true;
32
+ // Se consumen los placeholders bien formados; lo que sobra no puede tener llaves.
33
+ let wellFormed = true;
34
+ const leftover = value.replace(PLACEHOLDER_PATTERN, (_match, doubleName, singleName) => {
35
+ const name = doubleName ?? singleName ?? '';
36
+ if (!VALID_PLACEHOLDER.test(name))
37
+ wellFormed = false;
38
+ return '';
39
+ });
40
+ if (!wellFormed)
41
+ return false;
42
+ return !leftover.includes('{') && !leftover.includes('}');
50
43
  }
51
44
  defaultMessage(args) {
52
- return `${args.property} contiene placeholders mal formados; debe seguir el patron {nombre} con nombre = [a-zA-Z][a-zA-Z0-9_]*`;
45
+ return `${args.property} contiene placeholders mal formados; debe seguir el patron {nombre} o {{nombre}} con nombre = [a-zA-Z][a-zA-Z0-9_]*`;
53
46
  }
54
47
  };
55
48
  exports.IsTemplatePlaceholders = IsTemplatePlaceholders;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fiado/type-kit",
3
- "version": "3.419.0",
3
+ "version": "3.421.0",
4
4
  "description": "",
5
5
  "main": "bin/index.js",
6
6
  "types": "bin/index.d.ts",
@@ -0,0 +1,37 @@
1
+ import { Expose, Type } from 'class-transformer';
2
+ import { ArrayMaxSize, ArrayNotEmpty, ArrayUnique, IsArray, IsNotEmpty, IsOptional, IsString, MaxLength, ValidateNested } from 'class-validator';
3
+ import { MdmNotifyItem } from './MdmNotifyItem';
4
+ import { MDM_NOTIFY_MAX_BATCH } from '../constants/MdmNotifyBatchLimits';
5
+
6
+ /**
7
+ * Mensaje que viaja por la cola de messages-business para avisar en lote a los equipos.
8
+ * Reemplaza al `POST /private/messages/mdm-notify`, que se retira.
9
+ */
10
+ export class MdmNotifyQueueMessageRequest {
11
+ /**
12
+ * Equipos del lote. El tope es el mismo que el del lote de `loan-credit-business`.
13
+ * El imei repetido duplica el aviso en el equipo y colapsa los dos acuses en uno.
14
+ */
15
+ @Expose()
16
+ @IsArray()
17
+ @ArrayNotEmpty()
18
+ @ArrayMaxSize(MDM_NOTIFY_MAX_BATCH)
19
+ @ArrayUnique((item: MdmNotifyItem) => item.imei)
20
+ @ValidateNested({ each: true })
21
+ @Type(() => MdmNotifyItem)
22
+ items!: MdmNotifyItem[];
23
+
24
+ /** Tenant emisor del aviso. Si se omite, messages-business asume FIADO. */
25
+ @Expose()
26
+ @IsOptional()
27
+ @IsString()
28
+ @IsNotEmpty()
29
+ tenantId?: string;
30
+
31
+ /** Referencia con la que el productor correlaciona este lote. Es la que viaja al proveedor. */
32
+ @Expose()
33
+ @IsString()
34
+ @IsNotEmpty()
35
+ @MaxLength(64)
36
+ operationReference!: string;
37
+ }
@@ -23,6 +23,7 @@ export * from './dtos/TemplateMessageListFiltersRequest';
23
23
  export * from './constants/MdmNotifyBatchLimits';
24
24
  export * from './dtos/MdmNotifyItem';
25
25
  export * from './dtos/MdmNotifyRequest';
26
+ export * from './dtos/MdmNotifyQueueMessageRequest';
26
27
  export * from './dtos/MdmNotifyResponse';
27
28
  export * from './dtos/MdmNotifyBatchResponse';
28
29
  export * from './constants/MessageIdempotencyLimits';
@@ -3,11 +3,18 @@ import { ValidatorConstraint, ValidatorConstraintInterface, ValidationArguments
3
3
  const VALID_PLACEHOLDER = /^[a-zA-Z][a-zA-Z0-9_]*$/;
4
4
 
5
5
  /**
6
- * Valida que un string solo contenga placeholders bien formados `{varName}` donde
7
- * `varName` matchea `^[a-zA-Z][a-zA-Z0-9_]*$`. Rechaza:
6
+ * Las DOS formas que el renderizador sustituye: doble `{{monto}}` y simple `{monto}`.
7
+ * La doble va primero para que no gane el `{monto}` interno, igual que alla.
8
+ */
9
+ const PLACEHOLDER_PATTERN = /\{\{([^{}]*)\}\}|\{([^{}]*)\}/g;
10
+
11
+ /**
12
+ * Valida que un string solo contenga placeholders bien formados, en cualquiera de las dos
13
+ * formas del catalogo: `{varName}` o `{{varName}}`, con `varName` matcheando
14
+ * `^[a-zA-Z][a-zA-Z0-9_]*$`. Rechaza:
8
15
  * - Placeholders con espacios o caracteres invalidos (`{var name}`, `{var-1}`).
9
- * - Llaves sin balancear (`abc {def`, `abc } def`, `{ {nested}` ).
10
- * - Placeholders vacios (`{}`).
16
+ * - Llaves sin balancear (`abc {def`, `abc } def`, `{ {nested}`).
17
+ * - Placeholders vacios (`{}`, `{{}}`).
11
18
  * Acepta texto sin placeholders en absoluto.
12
19
  *
13
20
  * Uso en DTOs: `@Validate(IsTemplatePlaceholders)` sobre un campo string.
@@ -17,29 +24,19 @@ export class IsTemplatePlaceholders implements ValidatorConstraintInterface {
17
24
  validate(value: unknown): boolean {
18
25
  if (typeof value !== 'string') return false;
19
26
 
20
- // Detectar llaves desbalanceadas con un single-pass.
21
- let depth = 0;
22
- for (const ch of value) {
23
- if (ch === '{') {
24
- if (depth > 0) return false; // anidado no permitido
25
- depth = 1;
26
- } else if (ch === '}') {
27
- if (depth === 0) return false; // cierre sin apertura
28
- depth = 0;
29
- }
30
- }
31
- if (depth !== 0) return false; // apertura sin cierre
27
+ // Se consumen los placeholders bien formados; lo que sobra no puede tener llaves.
28
+ let wellFormed = true;
29
+ const leftover = value.replace(PLACEHOLDER_PATTERN, (_match, doubleName?: string, singleName?: string) => {
30
+ const name = doubleName ?? singleName ?? '';
31
+ if (!VALID_PLACEHOLDER.test(name)) wellFormed = false;
32
+ return '';
33
+ });
34
+ if (!wellFormed) return false;
32
35
 
33
- // Validar que cada `{...}` matchea el regex.
34
- const matches = value.match(/\{([^}]*)\}/g) ?? [];
35
- for (const raw of matches) {
36
- const inner = raw.slice(1, -1);
37
- if (!VALID_PLACEHOLDER.test(inner)) return false;
38
- }
39
- return true;
36
+ return !leftover.includes('{') && !leftover.includes('}');
40
37
  }
41
38
 
42
39
  defaultMessage(args: ValidationArguments): string {
43
- return `${args.property} contiene placeholders mal formados; debe seguir el patron {nombre} con nombre = [a-zA-Z][a-zA-Z0-9_]*`;
40
+ return `${args.property} contiene placeholders mal formados; debe seguir el patron {nombre} o {{nombre}} con nombre = [a-zA-Z][a-zA-Z0-9_]*`;
44
41
  }
45
42
  }