@fiado/type-kit 3.418.0 → 3.420.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
+ });
@@ -4,6 +4,7 @@ import { validate } from 'class-validator';
4
4
  import { NotificationQueueMessageRequestV2 } from '../../../src/messagesConnector/dtos/NotificationQueueMessageRequestV2';
5
5
  import { NotificationOriginEnum } from '../../../src/messagesConnector/enums/NotificationOriginEnum';
6
6
  import { DeliveryChannelExtendedEnum } from '../../../src/messagesConnector/enums/DeliveryChannelExtendedEnum';
7
+ import { MESSAGE_IDEMPOTENCY_KEY_MAX_LENGTH } from '../../../src/messagesConnector/constants/MessageIdempotencyLimits';
7
8
 
8
9
  const base = {
9
10
  templateId: 'OTP_CODE',
@@ -48,7 +49,7 @@ describe('NotificationQueueMessageRequestV2.origin', () => {
48
49
  });
49
50
  });
50
51
 
51
- describe('referenceMessageId — la llave de idempotencia de la cola', () => {
52
+ describe('idempotencyKey — la llave de deduplicacion, aparte de la correlacion', () => {
52
53
  const validQueueMessage = {
53
54
  templateId: 'COLLECTION_PRE_DUE_2_DAYS',
54
55
  channelType: DeliveryChannelExtendedEnum.SMS,
@@ -59,19 +60,35 @@ describe('referenceMessageId — la llave de idempotencia de la cola', () => {
59
60
  destination: '+525512345678',
60
61
  variables: { name: 'Ana' },
61
62
  };
63
+ const build = (over: Record<string, unknown> = {}) =>
64
+ plainToInstance(NotificationQueueMessageRequestV2, { ...validQueueMessage, ...over });
62
65
 
63
- // A diferencia de los DTOs HTTP, aca NO es @IsUUID: el productor manda su referencia de
64
- // negocio, y messages-business la usa como llave junto con el tenant.
65
- it('acepta una referencia que no es UUID', async () => {
66
- const dto = plainToInstance(NotificationQueueMessageRequestV2,
67
- { ...validQueueMessage, referenceMessageId: 'collections-2026-09-09-MORNING-0001' });
68
- expect(await validate(dto)).toEqual([]);
66
+ it('acepta una llave de negocio que no es UUID', async () => {
67
+ expect(await validate(build({ idempotencyKey: 'run-42:credit-7:PRE_DUE_2_DAYS' }))).toEqual([]);
69
68
  });
70
69
 
71
- it('sigue valido sin referencia: sin ella no hay deduplicacion, que es el comportamiento de siempre', async () => {
72
- const dto = plainToInstance(NotificationQueueMessageRequestV2, validQueueMessage);
70
+ // Sin llave no hay deduplicacion: es el comportamiento de todos los productores de hoy.
71
+ it('sigue valido sin llave', async () => {
72
+ const dto = build();
73
73
  expect(await validate(dto)).toEqual([]);
74
- expect(dto.referenceMessageId).toBeUndefined();
74
+ expect(dto.idempotencyKey).toBeUndefined();
75
+ });
76
+
77
+ // El `#` separa los tramos de la llave de MessageLogs_GT: adentro la haria ambigua.
78
+ it('rechaza una llave con "#"', async () => {
79
+ const errors = await validate(build({ idempotencyKey: 'run-42#credit-7' }));
80
+ expect(errors.some((e) => e.property === 'idempotencyKey' && e.constraints?.matches)).toBe(true);
81
+ });
82
+
83
+ it('rechaza una llave sin techo', async () => {
84
+ const errors = await validate(build({ idempotencyKey: 'x'.repeat(MESSAGE_IDEMPOTENCY_KEY_MAX_LENGTH + 1) }));
85
+ expect(errors.some((e) => e.property === 'idempotencyKey' && e.constraints?.maxLength)).toBe(true);
86
+ });
87
+
88
+ // referenceMessageId trae el id de la CORRIDA, no del mensaje: deduplicar con el aplastaria
89
+ // en uno todos los avisos de un mismo barrido.
90
+ it('referenceMessageId es correlacion y NO tiene las restricciones de la llave', async () => {
91
+ expect(await validate(build({ referenceMessageId: 'run-42#lo-que-sea' }))).toEqual([]);
75
92
  });
76
93
 
77
94
  it('ya no acepta messageLogId: era plomeria del lote retirado', () => {
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Largo maximo de la llave de idempotencia de un mensaje de la cola. Entra en la llave de
3
+ * `MessageLogs_GT`, asi que acotarla es lo que evita una llave sin techo.
4
+ */
5
+ export declare const MESSAGE_IDEMPOTENCY_KEY_MAX_LENGTH = 128;
@@ -0,0 +1,8 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MESSAGE_IDEMPOTENCY_KEY_MAX_LENGTH = void 0;
4
+ /**
5
+ * Largo maximo de la llave de idempotencia de un mensaje de la cola. Entra en la llave de
6
+ * `MessageLogs_GT`, asi que acotarla es lo que evita una llave sin techo.
7
+ */
8
+ exports.MESSAGE_IDEMPOTENCY_KEY_MAX_LENGTH = 128;
@@ -11,11 +11,19 @@ export declare class NotificationQueueMessageRequestV2 {
11
11
  directoryId: string;
12
12
  destination: string;
13
13
  variables: Record<string, string>;
14
+ /** Referencia de correlacion del productor. NO deduplica: para eso esta `idempotencyKey`. */
15
+ referenceMessageId?: string;
14
16
  /**
15
- * Referencia del productor. Cuando viaja, messages-business la usa como llave de
16
- * idempotencia: el par (tenantId, referenceMessageId) se entrega UNA sola vez.
17
+ * Llave de idempotencia, opcional y explicita. Cuando viaja, messages-business entrega UNA
18
+ * sola vez el trio (tenantId, idempotencyKey, channelType).
19
+ *
20
+ * Va aparte de `referenceMessageId` a proposito: esa referencia identifica la CORRIDA que
21
+ * produjo el mensaje, no al mensaje, asi que usarla para deduplicar aplastaria en uno todos
22
+ * los avisos de un mismo barrido. La llave tiene que distinguir al destinatario.
23
+ *
24
+ * Un `#` en el valor hace ambigua la llave: no se admite.
17
25
  */
18
- referenceMessageId?: string;
26
+ idempotencyKey?: string;
19
27
  data?: unknown;
20
28
  /** Quien disparo la notificacion. Sin default: su ausencia ya significa EVENT. */
21
29
  origin?: NotificationOriginEnum;
@@ -15,6 +15,7 @@ const class_transformer_1 = require("class-transformer");
15
15
  const DeliveryChannelExtendedEnum_1 = require("../enums/DeliveryChannelExtendedEnum");
16
16
  const NotificationOriginEnum_1 = require("../enums/NotificationOriginEnum");
17
17
  const IsStringValueRecord_1 = require("../validators/IsStringValueRecord");
18
+ const MessageIdempotencyLimits_1 = require("../constants/MessageIdempotencyLimits");
18
19
  class NotificationQueueMessageRequestV2 {
19
20
  }
20
21
  exports.NotificationQueueMessageRequestV2 = NotificationQueueMessageRequestV2;
@@ -77,6 +78,15 @@ __decorate([
77
78
  (0, class_validator_1.IsString)(),
78
79
  __metadata("design:type", String)
79
80
  ], NotificationQueueMessageRequestV2.prototype, "referenceMessageId", void 0);
81
+ __decorate([
82
+ (0, class_transformer_1.Expose)(),
83
+ (0, class_validator_1.IsOptional)(),
84
+ (0, class_validator_1.IsString)(),
85
+ (0, class_validator_1.IsNotEmpty)(),
86
+ (0, class_validator_1.MaxLength)(MessageIdempotencyLimits_1.MESSAGE_IDEMPOTENCY_KEY_MAX_LENGTH),
87
+ (0, class_validator_1.Matches)(/^[^#]+$/, { message: 'idempotencyKey no puede contener "#"' }),
88
+ __metadata("design:type", String)
89
+ ], NotificationQueueMessageRequestV2.prototype, "idempotencyKey", void 0);
80
90
  __decorate([
81
91
  (0, class_transformer_1.Expose)(),
82
92
  (0, class_validator_1.IsOptional)(),
@@ -25,3 +25,4 @@ export * from './dtos/MdmNotifyItem';
25
25
  export * from './dtos/MdmNotifyRequest';
26
26
  export * from './dtos/MdmNotifyResponse';
27
27
  export * from './dtos/MdmNotifyBatchResponse';
28
+ export * from './constants/MessageIdempotencyLimits';
@@ -41,3 +41,4 @@ __exportStar(require("./dtos/MdmNotifyItem"), exports);
41
41
  __exportStar(require("./dtos/MdmNotifyRequest"), exports);
42
42
  __exportStar(require("./dtos/MdmNotifyResponse"), exports);
43
43
  __exportStar(require("./dtos/MdmNotifyBatchResponse"), exports);
44
+ __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.418.0",
3
+ "version": "3.420.0",
4
4
  "description": "",
5
5
  "main": "bin/index.js",
6
6
  "types": "bin/index.d.ts",
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Largo maximo de la llave de idempotencia de un mensaje de la cola. Entra en la llave de
3
+ * `MessageLogs_GT`, asi que acotarla es lo que evita una llave sin techo.
4
+ */
5
+ export const MESSAGE_IDEMPOTENCY_KEY_MAX_LENGTH = 128;
@@ -1,8 +1,9 @@
1
- import { IsEnum, IsNotEmpty, IsObject, IsOptional, IsString, Validate } from 'class-validator';
1
+ import { IsEnum, IsNotEmpty, IsObject, IsOptional, IsString, Matches, MaxLength, Validate } from 'class-validator';
2
2
  import { Expose } from 'class-transformer';
3
3
  import { DeliveryChannelExtendedEnum } from '../enums/DeliveryChannelExtendedEnum';
4
4
  import { NotificationOriginEnum } from '../enums/NotificationOriginEnum';
5
5
  import { IsStringValueRecord } from '../validators/IsStringValueRecord';
6
+ import { MESSAGE_IDEMPOTENCY_KEY_MAX_LENGTH } from '../constants/MessageIdempotencyLimits';
6
7
 
7
8
  export class NotificationQueueMessageRequestV2 {
8
9
  /** Tenant emisor del mensaje. Si se omite, messages-business asume FIADO. */
@@ -50,14 +51,29 @@ export class NotificationQueueMessageRequestV2 {
50
51
  @Validate(IsStringValueRecord)
51
52
  variables!: Record<string, string>;
52
53
 
54
+ /** Referencia de correlacion del productor. NO deduplica: para eso esta `idempotencyKey`. */
55
+ @Expose()
56
+ @IsOptional()
57
+ @IsString()
58
+ referenceMessageId?: string;
59
+
53
60
  /**
54
- * Referencia del productor. Cuando viaja, messages-business la usa como llave de
55
- * idempotencia: el par (tenantId, referenceMessageId) se entrega UNA sola vez.
61
+ * Llave de idempotencia, opcional y explicita. Cuando viaja, messages-business entrega UNA
62
+ * sola vez el trio (tenantId, idempotencyKey, channelType).
63
+ *
64
+ * Va aparte de `referenceMessageId` a proposito: esa referencia identifica la CORRIDA que
65
+ * produjo el mensaje, no al mensaje, asi que usarla para deduplicar aplastaria en uno todos
66
+ * los avisos de un mismo barrido. La llave tiene que distinguir al destinatario.
67
+ *
68
+ * Un `#` en el valor hace ambigua la llave: no se admite.
56
69
  */
57
70
  @Expose()
58
71
  @IsOptional()
59
72
  @IsString()
60
- referenceMessageId?: string;
73
+ @IsNotEmpty()
74
+ @MaxLength(MESSAGE_IDEMPOTENCY_KEY_MAX_LENGTH)
75
+ @Matches(/^[^#]+$/, { message: 'idempotencyKey no puede contener "#"' })
76
+ idempotencyKey?: string;
61
77
 
62
78
  @Expose()
63
79
  @IsOptional()
@@ -25,3 +25,4 @@ export * from './dtos/MdmNotifyItem';
25
25
  export * from './dtos/MdmNotifyRequest';
26
26
  export * from './dtos/MdmNotifyResponse';
27
27
  export * from './dtos/MdmNotifyBatchResponse';
28
+ 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
  }