tychat-contracts 1.0.106 → 1.0.108

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.
@@ -1 +1 @@
1
- {"version":3,"file":"conversation-contact-filters-query.validator.d.ts","sourceRoot":"","sources":["../../src/conversations/conversation-contact-filters-query.validator.ts"],"names":[],"mappings":"AACA,OAAO,EAEL,mBAAmB,EAGnB,4BAA4B,EAC7B,MAAM,iBAAiB,CAAC;AAoBzB;;;;;;;GAOG;AACH,qBACa,yCACX,YAAW,4BAA4B;IAEvC,QAAQ,CAAC,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,mBAAmB,GAAG,OAAO;IAwH5D,cAAc,CAAC,IAAI,EAAE,mBAAmB,GAAG,MAAM;CAMlD"}
1
+ {"version":3,"file":"conversation-contact-filters-query.validator.d.ts","sourceRoot":"","sources":["../../src/conversations/conversation-contact-filters-query.validator.ts"],"names":[],"mappings":"AACA,OAAO,EAEL,mBAAmB,EAGnB,4BAA4B,EAC7B,MAAM,iBAAiB,CAAC;AA8CzB;;;;;;;GAOG;AACH,qBACa,yCACX,YAAW,4BAA4B;IAEvC,QAAQ,CAAC,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,mBAAmB,GAAG,OAAO;IAyH5D,cAAc,CAAC,IAAI,EAAE,mBAAmB,GAAG,MAAM;CAMlD"}
@@ -25,6 +25,30 @@ function flattenErrors(errors, prefix = '') {
25
25
  }
26
26
  return out;
27
27
  }
28
+ /** Alguns gateways/parsers envolvem o item em um ou mais arrays de um elemento. */
29
+ function unwrapSingleElementArrays(el, maxDepth = 8) {
30
+ let cur = el;
31
+ let d = 0;
32
+ while (Array.isArray(cur) && cur.length === 1 && d < maxDepth) {
33
+ cur = cur[0];
34
+ d += 1;
35
+ }
36
+ return cur;
37
+ }
38
+ /** JSON string que, ao dar parse, ainda é string (double-encoded), até virar objeto/array. */
39
+ function jsonParseLoose(text) {
40
+ let parsed = JSON.parse(text);
41
+ let guard = 0;
42
+ while (typeof parsed === 'string' && guard < 4) {
43
+ const s = parsed.trim();
44
+ if (s === '') {
45
+ break;
46
+ }
47
+ parsed = JSON.parse(s);
48
+ guard += 1;
49
+ }
50
+ return parsed;
51
+ }
28
52
  /**
29
53
  * Aceita na query (e no payload Kafka já desserializado):
30
54
  * - array de strings: cada string é JSON de um objeto `{ key, op, value }`
@@ -50,7 +74,7 @@ let ConversationContactFiltersQueryConstraint = class ConversationContactFilters
50
74
  }
51
75
  let parsed;
52
76
  try {
53
- parsed = JSON.parse(t);
77
+ parsed = jsonParseLoose(t);
54
78
  }
55
79
  catch {
56
80
  conversationContactFiltersQueryError.set(parent, 'filters: string não é JSON válido');
@@ -83,7 +107,7 @@ let ConversationContactFiltersQueryConstraint = class ConversationContactFilters
83
107
  }
84
108
  const out = [];
85
109
  for (let i = 0; i < rawItems.length; i++) {
86
- const el = rawItems[i];
110
+ const el = unwrapSingleElementArrays(rawItems[i]);
87
111
  let plain;
88
112
  if (typeof el === 'string') {
89
113
  const s = el.trim();
@@ -92,18 +116,19 @@ let ConversationContactFiltersQueryConstraint = class ConversationContactFilters
92
116
  return false;
93
117
  }
94
118
  try {
95
- plain = JSON.parse(s);
119
+ plain = jsonParseLoose(s);
96
120
  }
97
121
  catch {
98
122
  conversationContactFiltersQueryError.set(parent, `filters[${i}]: JSON inválido`);
99
123
  return false;
100
124
  }
125
+ plain = unwrapSingleElementArrays(plain);
101
126
  }
102
127
  else if (el !== null && typeof el === 'object' && !Array.isArray(el)) {
103
128
  plain = el;
104
129
  }
105
130
  else {
106
- conversationContactFiltersQueryError.set(parent, `filters[${i}] deve ser string JSON ou objeto`);
131
+ conversationContactFiltersQueryError.set(parent, `filters[${i}] deve ser string JSON, objeto, ou array de um único filtro (formato recebido inválido)`);
107
132
  return false;
108
133
  }
109
134
  if (plain === null ||
@@ -0,0 +1,2 @@
1
+ import 'reflect-metadata';
2
+ //# sourceMappingURL=conversation-contact-filters-query.validator.spec.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"conversation-contact-filters-query.validator.spec.d.ts","sourceRoot":"","sources":["../../src/conversations/conversation-contact-filters-query.validator.spec.ts"],"names":[],"mappings":"AAAA,OAAO,kBAAkB,CAAC"}
@@ -0,0 +1,189 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ require("reflect-metadata");
4
+ const class_transformer_1 = require("class-transformer");
5
+ const class_validator_1 = require("class-validator");
6
+ const list_conversation_contacts_query_dto_1 = require("./list-conversation-contacts-query.dto");
7
+ /**
8
+ * Simulates how NestJS ValidationPipe processes a raw query object
9
+ * when configured with transform: true and enableImplicitConversion: true.
10
+ */
11
+ function simulateValidationPipe(raw) {
12
+ return (0, class_transformer_1.plainToInstance)(list_conversation_contacts_query_dto_1.ListConversationContactsQueryDto, raw, {
13
+ enableImplicitConversion: true,
14
+ });
15
+ }
16
+ describe('ConversationContactFiltersQueryConstraint', () => {
17
+ it('should accept a single JSON object string (URL-decoded query param)', async () => {
18
+ const raw = {
19
+ page: '1',
20
+ limit: '20',
21
+ filters: '{"key":"intention","op":"==","value":"clinic_info"}',
22
+ };
23
+ const dto = simulateValidationPipe(raw);
24
+ const errors = await (0, class_validator_1.validate)(dto, {
25
+ whitelist: true,
26
+ forbidNonWhitelisted: true,
27
+ });
28
+ expect(errors).toHaveLength(0);
29
+ expect(dto.filters).toEqual([
30
+ expect.objectContaining({
31
+ key: 'intention',
32
+ op: '==',
33
+ value: 'clinic_info',
34
+ }),
35
+ ]);
36
+ });
37
+ it('should accept a JSON array string with one filter', async () => {
38
+ const raw = {
39
+ page: '1',
40
+ limit: '20',
41
+ filters: '[{"key":"intention","op":"==","value":"clinic_info"}]',
42
+ };
43
+ const dto = simulateValidationPipe(raw);
44
+ const errors = await (0, class_validator_1.validate)(dto, {
45
+ whitelist: true,
46
+ forbidNonWhitelisted: true,
47
+ });
48
+ expect(errors).toHaveLength(0);
49
+ expect(dto.filters).toHaveLength(1);
50
+ });
51
+ it('should accept a JSON array string with multiple filters', async () => {
52
+ const raw = {
53
+ filters: '[{"key":"intention","op":"==","value":"clinic_info"},{"key":"patientId","op":"!=","value":"abc"}]',
54
+ };
55
+ const dto = simulateValidationPipe(raw);
56
+ const errors = await (0, class_validator_1.validate)(dto, {
57
+ whitelist: true,
58
+ forbidNonWhitelisted: true,
59
+ });
60
+ expect(errors).toHaveLength(0);
61
+ expect(dto.filters).toHaveLength(2);
62
+ });
63
+ it('should accept an array of JSON strings (e.g. filters[0]=...&filters[1]=...)', async () => {
64
+ const raw = {
65
+ filters: [
66
+ '{"key":"intention","op":"==","value":"clinic_info"}',
67
+ '{"key":"patientId","op":"!=","value":"xyz"}',
68
+ ],
69
+ };
70
+ const dto = simulateValidationPipe(raw);
71
+ const errors = await (0, class_validator_1.validate)(dto, {
72
+ whitelist: true,
73
+ forbidNonWhitelisted: true,
74
+ });
75
+ expect(errors).toHaveLength(0);
76
+ expect(dto.filters).toHaveLength(2);
77
+ });
78
+ it('should accept an array of plain objects (Kafka / programmatic usage)', async () => {
79
+ const raw = {
80
+ filters: [
81
+ { key: 'intention', op: '==', value: 'clinic_info' },
82
+ ],
83
+ };
84
+ const dto = simulateValidationPipe(raw);
85
+ const errors = await (0, class_validator_1.validate)(dto, {
86
+ whitelist: true,
87
+ forbidNonWhitelisted: true,
88
+ });
89
+ expect(errors).toHaveLength(0);
90
+ expect(dto.filters).toHaveLength(1);
91
+ });
92
+ it('should accept a single plain object', async () => {
93
+ const raw = {
94
+ filters: { key: 'intention', op: '==', value: 'clinic_info' },
95
+ };
96
+ const dto = simulateValidationPipe(raw);
97
+ const errors = await (0, class_validator_1.validate)(dto, {
98
+ whitelist: true,
99
+ forbidNonWhitelisted: true,
100
+ });
101
+ expect(errors).toHaveLength(0);
102
+ expect(dto.filters).toHaveLength(1);
103
+ });
104
+ it('should accept undefined/null filters', async () => {
105
+ const dto = simulateValidationPipe({ page: '1', limit: '20' });
106
+ const errors = await (0, class_validator_1.validate)(dto, {
107
+ whitelist: true,
108
+ forbidNonWhitelisted: true,
109
+ });
110
+ expect(errors).toHaveLength(0);
111
+ expect(dto.filters).toBeUndefined();
112
+ });
113
+ it('should accept empty string filters', async () => {
114
+ const dto = simulateValidationPipe({ filters: '' });
115
+ const errors = await (0, class_validator_1.validate)(dto, {
116
+ whitelist: true,
117
+ forbidNonWhitelisted: true,
118
+ });
119
+ expect(errors).toHaveLength(0);
120
+ expect(dto.filters).toBeUndefined();
121
+ });
122
+ it('should reject invalid JSON string', async () => {
123
+ const dto = simulateValidationPipe({ filters: '{bad json' });
124
+ const errors = await (0, class_validator_1.validate)(dto, {
125
+ whitelist: true,
126
+ forbidNonWhitelisted: true,
127
+ });
128
+ expect(errors.length).toBeGreaterThan(0);
129
+ });
130
+ it('should reject a filter with invalid key', async () => {
131
+ const raw = {
132
+ filters: '{"key":"invalidField","op":"==","value":"x"}',
133
+ };
134
+ const dto = simulateValidationPipe(raw);
135
+ const errors = await (0, class_validator_1.validate)(dto, {
136
+ whitelist: true,
137
+ forbidNonWhitelisted: true,
138
+ });
139
+ expect(errors.length).toBeGreaterThan(0);
140
+ });
141
+ it('should reject a filter with invalid operator', async () => {
142
+ const raw = {
143
+ filters: '{"key":"intention","op":"like","value":"x"}',
144
+ };
145
+ const dto = simulateValidationPipe(raw);
146
+ const errors = await (0, class_validator_1.validate)(dto, {
147
+ whitelist: true,
148
+ forbidNonWhitelisted: true,
149
+ });
150
+ expect(errors.length).toBeGreaterThan(0);
151
+ });
152
+ it('should accept op "in" with array value', async () => {
153
+ const raw = {
154
+ filters: '{"key":"intention","op":"in","value":["clinic_info","other"]}',
155
+ };
156
+ const dto = simulateValidationPipe(raw);
157
+ const errors = await (0, class_validator_1.validate)(dto, {
158
+ whitelist: true,
159
+ forbidNonWhitelisted: true,
160
+ });
161
+ expect(errors).toHaveLength(0);
162
+ });
163
+ it('should reject op "in" with scalar value', async () => {
164
+ const raw = {
165
+ filters: '{"key":"intention","op":"in","value":"clinic_info"}',
166
+ };
167
+ const dto = simulateValidationPipe(raw);
168
+ const errors = await (0, class_validator_1.validate)(dto, {
169
+ whitelist: true,
170
+ forbidNonWhitelisted: true,
171
+ });
172
+ expect(errors.length).toBeGreaterThan(0);
173
+ });
174
+ it('should handle double-encoded JSON string', async () => {
175
+ const inner = JSON.stringify({
176
+ key: 'intention',
177
+ op: '==',
178
+ value: 'clinic_info',
179
+ });
180
+ const raw = { filters: JSON.stringify(inner) };
181
+ const dto = simulateValidationPipe(raw);
182
+ const errors = await (0, class_validator_1.validate)(dto, {
183
+ whitelist: true,
184
+ forbidNonWhitelisted: true,
185
+ });
186
+ expect(errors).toHaveLength(0);
187
+ expect(dto.filters).toHaveLength(1);
188
+ });
189
+ });
@@ -1 +1 @@
1
- {"version":3,"file":"list-conversation-contacts-query.dto.d.ts","sourceRoot":"","sources":["../../src/conversations/list-conversation-contacts-query.dto.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,gCAAgC,EAAE,MAAM,0CAA0C,CAAC;AAE5F,qBAAa,gCAAgC;IAU3C,IAAI,CAAC,EAAE,MAAM,CAAC;IAad,KAAK,CAAC,EAAE,MAAM,CAAC;IAWf,OAAO,CAAC,EAAE,gCAAgC,EAAE,CAAC;CAC9C"}
1
+ {"version":3,"file":"list-conversation-contacts-query.dto.d.ts","sourceRoot":"","sources":["../../src/conversations/list-conversation-contacts-query.dto.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,gCAAgC,EAAE,MAAM,0CAA0C,CAAC;AAqB5F,qBAAa,gCAAgC;IAU3C,IAAI,CAAC,EAAE,MAAM,CAAC;IAad,KAAK,CAAC,EAAE,MAAM,CAAC;IAYf,OAAO,CAAC,EAAE,gCAAgC,EAAE,CAAC;CAC9C"}
@@ -13,6 +13,24 @@ exports.ListConversationContactsQueryDto = void 0;
13
13
  const swagger_1 = require("@nestjs/swagger");
14
14
  const class_validator_1 = require("class-validator");
15
15
  const conversation_contact_filters_query_validator_1 = require("./conversation-contact-filters-query.validator");
16
+ /**
17
+ * Overrides TypeScript's reflected `design:type` metadata to `Object`.
18
+ *
19
+ * When `enableImplicitConversion` is true, class-transformer uses the reflected
20
+ * type to recursively transform array elements. For properties like `filters`
21
+ * whose raw value (string / mixed array) is fully parsed by a custom validator,
22
+ * the implicit conversion mangles the value before the validator sees it.
23
+ *
24
+ * Placing this decorator **first** (topmost) ensures it runs **last** in the
25
+ * decorator chain, overriding the `__metadata("design:type", Array)` emitted
26
+ * by TypeScript. With `design:type = Object`, class-transformer leaves the raw
27
+ * value intact.
28
+ */
29
+ function RawQueryParam() {
30
+ return (target, propertyKey) => {
31
+ Reflect.defineMetadata('design:type', Object, target, propertyKey);
32
+ };
33
+ }
16
34
  class ListConversationContactsQueryDto {
17
35
  page;
18
36
  limit;
@@ -52,6 +70,8 @@ __decorate([
52
70
  items: { type: 'string' },
53
71
  example: ['{"key":"intention","op":"==","value":"clinic_info"}'],
54
72
  }),
73
+ RawQueryParam() // Must be first — overrides design:type from Array to Object so class-transformer won't mangle the value
74
+ ,
55
75
  (0, class_validator_1.IsOptional)(),
56
76
  (0, class_validator_1.Validate)(conversation_contact_filters_query_validator_1.ConversationContactFiltersQueryConstraint),
57
77
  __metadata("design:type", Array)
package/jest.config.ts ADDED
@@ -0,0 +1,5 @@
1
+ export default {
2
+ preset: 'ts-jest',
3
+ testEnvironment: 'node',
4
+ roots: ['<rootDir>/src'],
5
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tychat-contracts",
3
- "version": "1.0.106",
3
+ "version": "1.0.108",
4
4
  "description": "DTOs compartilhados com class-validator (API e microserviços)",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -18,6 +18,8 @@
18
18
  },
19
19
  "devDependencies": {
20
20
  "@nestjs/swagger": "^11.2.6",
21
+ "@types/jest": "^30.0.0",
22
+ "ts-jest": "^29.4.9",
21
23
  "typescript": "^5.7.3"
22
24
  },
23
25
  "peerDependencies": {
@@ -0,0 +1,29 @@
1
+ require('reflect-metadata');
2
+ const { plainToInstance } = require('class-transformer');
3
+ const { validateSync } = require('class-validator');
4
+ const {
5
+ ListConversationContactsQueryDto,
6
+ } = require('../dist/conversations/list-conversation-contacts-query.dto.js');
7
+
8
+ const obj = {
9
+ key: 'intention',
10
+ op: '==',
11
+ value: 'clinic_info',
12
+ };
13
+ const r = plainToInstance(
14
+ ListConversationContactsQueryDto,
15
+ { page: '1', limit: '20', filters: obj },
16
+ { enableImplicitConversion: true },
17
+ );
18
+ const errors = validateSync(r, {
19
+ whitelist: true,
20
+ forbidNonWhitelisted: true,
21
+ });
22
+ process.stdout.write('errors=' + JSON.stringify(errors) + '\n');
23
+ process.stdout.write(
24
+ 'len=' +
25
+ String(r.filters?.length) +
26
+ ' isArray=' +
27
+ String(Array.isArray(r.filters)) +
28
+ '\n',
29
+ );
@@ -0,0 +1,205 @@
1
+ import 'reflect-metadata';
2
+ import { plainToInstance } from 'class-transformer';
3
+ import { validate } from 'class-validator';
4
+ import { ListConversationContactsQueryDto } from './list-conversation-contacts-query.dto';
5
+
6
+ /**
7
+ * Simulates how NestJS ValidationPipe processes a raw query object
8
+ * when configured with transform: true and enableImplicitConversion: true.
9
+ */
10
+ function simulateValidationPipe(
11
+ raw: Record<string, unknown>,
12
+ ): ListConversationContactsQueryDto {
13
+ return plainToInstance(ListConversationContactsQueryDto, raw, {
14
+ enableImplicitConversion: true,
15
+ });
16
+ }
17
+
18
+ describe('ConversationContactFiltersQueryConstraint', () => {
19
+ it('should accept a single JSON object string (URL-decoded query param)', async () => {
20
+ const raw = {
21
+ page: '1',
22
+ limit: '20',
23
+ filters: '{"key":"intention","op":"==","value":"clinic_info"}',
24
+ };
25
+ const dto = simulateValidationPipe(raw);
26
+ const errors = await validate(dto, {
27
+ whitelist: true,
28
+ forbidNonWhitelisted: true,
29
+ });
30
+ expect(errors).toHaveLength(0);
31
+ expect(dto.filters).toEqual([
32
+ expect.objectContaining({
33
+ key: 'intention',
34
+ op: '==',
35
+ value: 'clinic_info',
36
+ }),
37
+ ]);
38
+ });
39
+
40
+ it('should accept a JSON array string with one filter', async () => {
41
+ const raw = {
42
+ page: '1',
43
+ limit: '20',
44
+ filters: '[{"key":"intention","op":"==","value":"clinic_info"}]',
45
+ };
46
+ const dto = simulateValidationPipe(raw);
47
+ const errors = await validate(dto, {
48
+ whitelist: true,
49
+ forbidNonWhitelisted: true,
50
+ });
51
+ expect(errors).toHaveLength(0);
52
+ expect(dto.filters).toHaveLength(1);
53
+ });
54
+
55
+ it('should accept a JSON array string with multiple filters', async () => {
56
+ const raw = {
57
+ filters:
58
+ '[{"key":"intention","op":"==","value":"clinic_info"},{"key":"patientId","op":"!=","value":"abc"}]',
59
+ };
60
+ const dto = simulateValidationPipe(raw);
61
+ const errors = await validate(dto, {
62
+ whitelist: true,
63
+ forbidNonWhitelisted: true,
64
+ });
65
+ expect(errors).toHaveLength(0);
66
+ expect(dto.filters).toHaveLength(2);
67
+ });
68
+
69
+ it('should accept an array of JSON strings (e.g. filters[0]=...&filters[1]=...)', async () => {
70
+ const raw = {
71
+ filters: [
72
+ '{"key":"intention","op":"==","value":"clinic_info"}',
73
+ '{"key":"patientId","op":"!=","value":"xyz"}',
74
+ ],
75
+ };
76
+ const dto = simulateValidationPipe(raw);
77
+ const errors = await validate(dto, {
78
+ whitelist: true,
79
+ forbidNonWhitelisted: true,
80
+ });
81
+ expect(errors).toHaveLength(0);
82
+ expect(dto.filters).toHaveLength(2);
83
+ });
84
+
85
+ it('should accept an array of plain objects (Kafka / programmatic usage)', async () => {
86
+ const raw = {
87
+ filters: [
88
+ { key: 'intention', op: '==', value: 'clinic_info' },
89
+ ],
90
+ };
91
+ const dto = simulateValidationPipe(raw);
92
+ const errors = await validate(dto, {
93
+ whitelist: true,
94
+ forbidNonWhitelisted: true,
95
+ });
96
+ expect(errors).toHaveLength(0);
97
+ expect(dto.filters).toHaveLength(1);
98
+ });
99
+
100
+ it('should accept a single plain object', async () => {
101
+ const raw = {
102
+ filters: { key: 'intention', op: '==', value: 'clinic_info' },
103
+ };
104
+ const dto = simulateValidationPipe(raw);
105
+ const errors = await validate(dto, {
106
+ whitelist: true,
107
+ forbidNonWhitelisted: true,
108
+ });
109
+ expect(errors).toHaveLength(0);
110
+ expect(dto.filters).toHaveLength(1);
111
+ });
112
+
113
+ it('should accept undefined/null filters', async () => {
114
+ const dto = simulateValidationPipe({ page: '1', limit: '20' });
115
+ const errors = await validate(dto, {
116
+ whitelist: true,
117
+ forbidNonWhitelisted: true,
118
+ });
119
+ expect(errors).toHaveLength(0);
120
+ expect(dto.filters).toBeUndefined();
121
+ });
122
+
123
+ it('should accept empty string filters', async () => {
124
+ const dto = simulateValidationPipe({ filters: '' });
125
+ const errors = await validate(dto, {
126
+ whitelist: true,
127
+ forbidNonWhitelisted: true,
128
+ });
129
+ expect(errors).toHaveLength(0);
130
+ expect(dto.filters).toBeUndefined();
131
+ });
132
+
133
+ it('should reject invalid JSON string', async () => {
134
+ const dto = simulateValidationPipe({ filters: '{bad json' });
135
+ const errors = await validate(dto, {
136
+ whitelist: true,
137
+ forbidNonWhitelisted: true,
138
+ });
139
+ expect(errors.length).toBeGreaterThan(0);
140
+ });
141
+
142
+ it('should reject a filter with invalid key', async () => {
143
+ const raw = {
144
+ filters: '{"key":"invalidField","op":"==","value":"x"}',
145
+ };
146
+ const dto = simulateValidationPipe(raw);
147
+ const errors = await validate(dto, {
148
+ whitelist: true,
149
+ forbidNonWhitelisted: true,
150
+ });
151
+ expect(errors.length).toBeGreaterThan(0);
152
+ });
153
+
154
+ it('should reject a filter with invalid operator', async () => {
155
+ const raw = {
156
+ filters: '{"key":"intention","op":"like","value":"x"}',
157
+ };
158
+ const dto = simulateValidationPipe(raw);
159
+ const errors = await validate(dto, {
160
+ whitelist: true,
161
+ forbidNonWhitelisted: true,
162
+ });
163
+ expect(errors.length).toBeGreaterThan(0);
164
+ });
165
+
166
+ it('should accept op "in" with array value', async () => {
167
+ const raw = {
168
+ filters: '{"key":"intention","op":"in","value":["clinic_info","other"]}',
169
+ };
170
+ const dto = simulateValidationPipe(raw);
171
+ const errors = await validate(dto, {
172
+ whitelist: true,
173
+ forbidNonWhitelisted: true,
174
+ });
175
+ expect(errors).toHaveLength(0);
176
+ });
177
+
178
+ it('should reject op "in" with scalar value', async () => {
179
+ const raw = {
180
+ filters: '{"key":"intention","op":"in","value":"clinic_info"}',
181
+ };
182
+ const dto = simulateValidationPipe(raw);
183
+ const errors = await validate(dto, {
184
+ whitelist: true,
185
+ forbidNonWhitelisted: true,
186
+ });
187
+ expect(errors.length).toBeGreaterThan(0);
188
+ });
189
+
190
+ it('should handle double-encoded JSON string', async () => {
191
+ const inner = JSON.stringify({
192
+ key: 'intention',
193
+ op: '==',
194
+ value: 'clinic_info',
195
+ });
196
+ const raw = { filters: JSON.stringify(inner) };
197
+ const dto = simulateValidationPipe(raw);
198
+ const errors = await validate(dto, {
199
+ whitelist: true,
200
+ forbidNonWhitelisted: true,
201
+ });
202
+ expect(errors).toHaveLength(0);
203
+ expect(dto.filters).toHaveLength(1);
204
+ });
205
+ });
@@ -25,6 +25,32 @@ function flattenErrors(errors: ValidationError[], prefix = ''): string[] {
25
25
  return out;
26
26
  }
27
27
 
28
+ /** Alguns gateways/parsers envolvem o item em um ou mais arrays de um elemento. */
29
+ function unwrapSingleElementArrays(el: unknown, maxDepth = 8): unknown {
30
+ let cur = el;
31
+ let d = 0;
32
+ while (Array.isArray(cur) && cur.length === 1 && d < maxDepth) {
33
+ cur = cur[0];
34
+ d += 1;
35
+ }
36
+ return cur;
37
+ }
38
+
39
+ /** JSON string que, ao dar parse, ainda é string (double-encoded), até virar objeto/array. */
40
+ function jsonParseLoose(text: string): unknown {
41
+ let parsed: unknown = JSON.parse(text);
42
+ let guard = 0;
43
+ while (typeof parsed === 'string' && guard < 4) {
44
+ const s = parsed.trim();
45
+ if (s === '') {
46
+ break;
47
+ }
48
+ parsed = JSON.parse(s);
49
+ guard += 1;
50
+ }
51
+ return parsed;
52
+ }
53
+
28
54
  /**
29
55
  * Aceita na query (e no payload Kafka já desserializado):
30
56
  * - array de strings: cada string é JSON de um objeto `{ key, op, value }`
@@ -55,7 +81,7 @@ export class ConversationContactFiltersQueryConstraint
55
81
  }
56
82
  let parsed: unknown;
57
83
  try {
58
- parsed = JSON.parse(t);
84
+ parsed = jsonParseLoose(t);
59
85
  } catch {
60
86
  conversationContactFiltersQueryError.set(
61
87
  parent,
@@ -96,7 +122,7 @@ export class ConversationContactFiltersQueryConstraint
96
122
 
97
123
  const out: ConversationContactListFilterDto[] = [];
98
124
  for (let i = 0; i < rawItems.length; i++) {
99
- const el = rawItems[i];
125
+ const el = unwrapSingleElementArrays(rawItems[i]);
100
126
  let plain: unknown;
101
127
  if (typeof el === 'string') {
102
128
  const s = el.trim();
@@ -108,7 +134,7 @@ export class ConversationContactFiltersQueryConstraint
108
134
  return false;
109
135
  }
110
136
  try {
111
- plain = JSON.parse(s);
137
+ plain = jsonParseLoose(s);
112
138
  } catch {
113
139
  conversationContactFiltersQueryError.set(
114
140
  parent,
@@ -116,12 +142,13 @@ export class ConversationContactFiltersQueryConstraint
116
142
  );
117
143
  return false;
118
144
  }
145
+ plain = unwrapSingleElementArrays(plain);
119
146
  } else if (el !== null && typeof el === 'object' && !Array.isArray(el)) {
120
147
  plain = el;
121
148
  } else {
122
149
  conversationContactFiltersQueryError.set(
123
150
  parent,
124
- `filters[${i}] deve ser string JSON ou objeto`,
151
+ `filters[${i}] deve ser string JSON, objeto, ou array de um único filtro (formato recebido inválido)`,
125
152
  );
126
153
  return false;
127
154
  }
@@ -3,6 +3,25 @@ import { IsInt, IsOptional, Max, Min, Validate } from 'class-validator';
3
3
  import { ConversationContactFiltersQueryConstraint } from './conversation-contact-filters-query.validator';
4
4
  import { ConversationContactListFilterDto } from './list-conversation-contacts-filters.dto';
5
5
 
6
+ /**
7
+ * Overrides TypeScript's reflected `design:type` metadata to `Object`.
8
+ *
9
+ * When `enableImplicitConversion` is true, class-transformer uses the reflected
10
+ * type to recursively transform array elements. For properties like `filters`
11
+ * whose raw value (string / mixed array) is fully parsed by a custom validator,
12
+ * the implicit conversion mangles the value before the validator sees it.
13
+ *
14
+ * Placing this decorator **first** (topmost) ensures it runs **last** in the
15
+ * decorator chain, overriding the `__metadata("design:type", Array)` emitted
16
+ * by TypeScript. With `design:type = Object`, class-transformer leaves the raw
17
+ * value intact.
18
+ */
19
+ function RawQueryParam(): PropertyDecorator {
20
+ return (target, propertyKey) => {
21
+ Reflect.defineMetadata('design:type', Object, target, propertyKey);
22
+ };
23
+ }
24
+
6
25
  export class ListConversationContactsQueryDto {
7
26
  @ApiPropertyOptional({
8
27
  description: 'Número da página (iniciando em 1)',
@@ -35,6 +54,7 @@ export class ListConversationContactsQueryDto {
35
54
  items: { type: 'string' },
36
55
  example: ['{"key":"intention","op":"==","value":"clinic_info"}'],
37
56
  })
57
+ @RawQueryParam() // Must be first — overrides design:type from Array to Object so class-transformer won't mangle the value
38
58
  @IsOptional()
39
59
  @Validate(ConversationContactFiltersQueryConstraint)
40
60
  filters?: ConversationContactListFilterDto[];