Skip to content

Commit 9ac534b

Browse files
committed
feat: encode/decode choices and improve code structure
1 parent 17fa11b commit 9ac534b

File tree

9 files changed

+795
-133
lines changed

9 files changed

+795
-133
lines changed

.secrets.baseline

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"files": null,
44
"lines": null
55
},
6-
"generated_at": "2025-10-28T02:41:25Z",
6+
"generated_at": "2025-10-28T23:54:36Z",
77
"plugins_used": [
88
{
99
"name": "AWSKeyDetector"
@@ -227,6 +227,52 @@
227227
"verified_result": null
228228
}
229229
],
230+
"apps/web/app/Helpers/__tests__/actual-api-format.test.ts": [
231+
{
232+
"hashed_secret": "1eb9369bcdb20f6c88138c9427f009b6afe19c7c",
233+
"is_verified": false,
234+
"line_number": 22,
235+
"type": "Base64 High Entropy String",
236+
"verified_result": null
237+
},
238+
{
239+
"hashed_secret": "655dc5abb6b0ee511e4676b0739bb2077937014a",
240+
"is_verified": false,
241+
"line_number": 22,
242+
"type": "Base64 High Entropy String",
243+
"verified_result": null
244+
},
245+
{
246+
"hashed_secret": "6eff76dbb551ea7769517f3a7188862f85a7354e",
247+
"is_verified": false,
248+
"line_number": 69,
249+
"type": "Base64 High Entropy String",
250+
"verified_result": null
251+
},
252+
{
253+
"hashed_secret": "4f043b03a11e8b760986f07490ee76f48c6c7342",
254+
"is_verified": false,
255+
"line_number": 71,
256+
"type": "Base64 High Entropy String",
257+
"verified_result": null
258+
},
259+
{
260+
"hashed_secret": "618eb19f52dfe817f292f54554346cad3e3a88b3",
261+
"is_verified": false,
262+
"line_number": 71,
263+
"type": "Base64 High Entropy String",
264+
"verified_result": null
265+
}
266+
],
267+
"apps/web/app/Helpers/__tests__/choice-feedback-decoding.test.ts": [
268+
{
269+
"hashed_secret": "6eff76dbb551ea7769517f3a7188862f85a7354e",
270+
"is_verified": false,
271+
"line_number": 236,
272+
"type": "Base64 High Entropy String",
273+
"verified_result": null
274+
}
275+
],
230276
"apps/web/app/api/markChat/route.ts": [
231277
{
232278
"hashed_secret": "3f70a0183ead614880b70bff64fd39c0d9155d62",

apps/api/src/common/interceptors/data-transform.interceptor.ts

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
import { Reflector } from "@nestjs/core";
88
import { Observable } from "rxjs";
99
import { map } from "rxjs/operators";
10+
import { TRANSFORM_FIELDS } from "../../helpers/transform-config";
1011

1112
export interface TransformOptions {
1213
fields?: string[];
@@ -198,19 +199,7 @@ export class DataTransformInterceptor implements NestInterceptor {
198199
return {
199200
encodeResponse: true,
200201
decodeRequest: true,
201-
fields: [
202-
"introduction",
203-
"instructions",
204-
"gradingCriteriaOverview",
205-
"question",
206-
"content",
207-
"rubricQuestion",
208-
"description",
209-
"questions.scoring.rubrics.rubricQuestion",
210-
"questions.scoring.rubrics.criteria.description",
211-
"responsesForQuestions.learnerTextResponse",
212-
"responsesForQuestions.learnerChoices",
213-
],
202+
fields: [...TRANSFORM_FIELDS],
214203
deep: true,
215204
};
216205
}

apps/api/src/helpers/data-transformer.ts

Lines changed: 33 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,28 @@
1+
import type { API_CONFIG, DATABASE_CONFIG } from "./transform-config";
2+
13
export interface TransformConfig {
24
fields?: string[];
35
exclude?: string[];
46
deep?: boolean;
57
preserveTypes?: boolean;
68
}
79

10+
// Lazy-loaded config to avoid circular dependencies
11+
let _transformConfig:
12+
| {
13+
DATABASE_CONFIG: typeof DATABASE_CONFIG;
14+
API_CONFIG: typeof API_CONFIG;
15+
}
16+
| undefined;
17+
18+
function getTransformConfig() {
19+
if (!_transformConfig) {
20+
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment, unicorn/prefer-module
21+
_transformConfig = require("./transform-config");
22+
}
23+
return _transformConfig;
24+
}
25+
826
export interface TransformResult<T = any> {
927
data: T;
1028
metadata: {
@@ -15,7 +33,6 @@ export interface TransformResult<T = any> {
1533
};
1634
}
1735

18-
const HTML_TAG_REGEX = /<\/?[a-z][\S\s]*>/i;
1936
const BASE64_FULL_REGEX = /^[\d+/A-Za-z]+={0,2}$/;
2037
const BASE64_SEGMENT_REGEX = /[\d+/=A-Za-z]{4,}/g;
2138
const MAX_BASE64_DEPTH = 5;
@@ -418,59 +435,27 @@ export function batchDecode<T = any>(
418435
}
419436

420437
export const DataTransformer = {
421-
encodeForDatabase: <T>(data: T) => {
422-
const result = smartEncode(data, {
423-
fields: [
424-
"introduction",
425-
"instructions",
426-
"gradingCriteriaOverview",
427-
"question",
428-
"content",
429-
"rubricQuestion",
430-
"description",
431-
"questions.choices.choice",
432-
"questions.scoring.rubrics.rubricQuestion",
433-
"questions.scoring.rubrics.criteria.description",
434-
"learnerTextResponse",
435-
"learnerChoices",
436-
],
437-
deep: true,
438-
});
438+
encodeForDatabase: <T>(data: T, config?: TransformConfig) => {
439+
const { DATABASE_CONFIG } = getTransformConfig();
440+
const result = smartEncode(data, config || DATABASE_CONFIG);
439441
return result;
440442
},
441443

442-
decodeFromDatabase: <T>(data: T) => {
443-
const result = smartDecode(data, {
444-
fields: [
445-
"introduction",
446-
"instructions",
447-
"gradingCriteriaOverview",
448-
"question",
449-
"content",
450-
"rubricQuestion",
451-
"description",
452-
"questions.choices.choice",
453-
"questions.scoring.rubrics.rubricQuestion",
454-
"questions.scoring.rubrics.criteria.description",
455-
"responsesForQuestions.learnerTextResponse",
456-
"responsesForQuestions.learnerChoices",
457-
],
458-
deep: true,
459-
});
444+
decodeFromDatabase: <T>(data: T, config?: TransformConfig) => {
445+
const { DATABASE_CONFIG } = getTransformConfig();
446+
const result = smartDecode(data, config || DATABASE_CONFIG);
460447
return result;
461448
},
462449

463-
encodeForAPI: <T>(data: T) =>
464-
smartEncode(data, {
465-
exclude: ["id", "createdAt", "updatedAt"],
466-
deep: true,
467-
}),
468-
469-
decodeFromAPI: <T>(data: T) =>
470-
smartDecode(data, {
471-
exclude: ["id", "createdAt", "updatedAt"],
472-
deep: true,
473-
}),
450+
encodeForAPI: <T>(data: T, config?: TransformConfig) => {
451+
const { API_CONFIG } = getTransformConfig();
452+
return smartEncode(data, config || API_CONFIG);
453+
},
454+
455+
decodeFromAPI: <T>(data: T, config?: TransformConfig) => {
456+
const { API_CONFIG } = getTransformConfig();
457+
return smartDecode(data, config || API_CONFIG);
458+
},
474459

475460
batchEncode,
476461
batchDecode,
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { TransformConfig } from "./data-transformer";
2+
3+
/**
4+
* Centralized transform configuration for encoding and decoding data.
5+
* This ensures consistency across the application and provides a single source of truth.
6+
*/
7+
8+
/**
9+
* Fields that should be base64 encoded/decoded for API transmission and storage
10+
*/
11+
export const TRANSFORM_FIELDS = [
12+
"introduction",
13+
"instructions",
14+
"gradingCriteriaOverview",
15+
16+
"question",
17+
"content",
18+
"rubricQuestion",
19+
"description",
20+
21+
"choice",
22+
"feedback",
23+
"choices.choice",
24+
"choices.feedback",
25+
"questions.choices.choice",
26+
"questions.choices.feedback",
27+
28+
"questions.scoring.rubrics.rubricQuestion",
29+
"questions.scoring.rubrics.criteria.description",
30+
31+
"learnerTextResponse",
32+
"learnerChoices",
33+
34+
"questionVersions.choices.choice",
35+
"questionVersions.choices.feedback",
36+
"questionVersions.scoring.rubrics.rubricQuestion",
37+
"questionVersions.scoring.rubrics.criteria.description",
38+
"questionVersions.question",
39+
] as const;
40+
41+
/**
42+
* Default configuration for database encoding/decoding
43+
*/
44+
export const DATABASE_CONFIG: TransformConfig = {
45+
fields: [...TRANSFORM_FIELDS],
46+
deep: true,
47+
};
48+
49+
/**
50+
* Configuration for API encoding/decoding (used when backend acts as pass-through)
51+
*/
52+
export const API_CONFIG: TransformConfig = {
53+
fields: [...TRANSFORM_FIELDS],
54+
exclude: ["id", "createdAt", "updatedAt"],
55+
deep: true,
56+
};
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { DataTransformer } from "../data-transformer";
2+
import { API_DECODE_CONFIG } from "../transform-config";
3+
4+
/**
5+
* Test with the ACTUAL API response format from production
6+
*/
7+
describe("Actual API Response Format", () => {
8+
it("should decode choices that come as a JSON string", () => {
9+
// This is the EXACT format from the user's API response
10+
const apiResponse = {
11+
id: 2246,
12+
questionId: 6998,
13+
totalPoints: 2,
14+
type: "SINGLE_CORRECT",
15+
responseType: "OTHER",
16+
question:
17+
"PHA+V2hpY2ggU1FMIHF1ZXJ5IGNvcnJlY3RseSByZXRyaWV2ZXMgdGhlIGZpcnN0IDEwMCByb3dzIGZyb20gdGhlIHNhbGVzX2RldGFpbCB0YWJsZSBpbiBQb3N0Z3JlU1FMPzwvcD4=",
18+
maxWords: null,
19+
scoring: null,
20+
// NOTE: choices comes as a JSON STRING, not an array!
21+
choices:
22+
'[{"choice":"U0VMRUNUICogRlJPTSBzYWxlc19kZXRhaWwgTElNSVQgMTAwOw==","isCorrect":true,"points":2,"feedback":"Q29ycmVjdCEgVGhpcyBxdWVyeSByZXRyaWV2ZXMgdGhlIGZpcnN0IDEwMCByb3dzIGZyb20gdGhlIHNhbGVzX2RldGFpbCB0YWJsZSB1c2luZyB0aGUgTElNSVQgY2xhdXNlLg=="},{"choice":"U0VMRUNUIFRPUCAxMDAgKiBGUk9NIHNhbGVzX2RldGFpbDs=","isCorrect":false,"points":0,"feedback":"SW5jb3JyZWN0LiBUaGUgVE9QIGNsYXVzZSBpcyB1c2VkIGluIFNRTCBTZXJ2ZXIsIG5vdCBpbiBQb3N0Z3JlU1FMLg=="}]',
23+
randomizedChoices: true,
24+
};
25+
26+
console.log("\n=== BEFORE DECODE ===");
27+
console.log("Question (base64):", apiResponse.question);
28+
console.log("Choices type:", typeof apiResponse.choices);
29+
console.log(
30+
"Choices value:",
31+
apiResponse.choices.substring(0, 100) + "...",
32+
);
33+
34+
// Decode using the DataTransformer
35+
const decoded = DataTransformer.decodeFromAPI(
36+
apiResponse,
37+
API_DECODE_CONFIG,
38+
);
39+
40+
console.log("\n=== AFTER DECODE ===");
41+
console.log("Question:", decoded.question);
42+
console.log("Choices type:", typeof decoded.choices);
43+
console.log("Choices:", JSON.stringify(decoded.choices, null, 2));
44+
45+
// Verify the question is decoded
46+
expect(decoded.question).toBe(
47+
"<p>Which SQL query correctly retrieves the first 100 rows from the sales_detail table in PostgreSQL?</p>",
48+
);
49+
50+
// Verify choices is now an array (parsed from JSON string)
51+
expect(Array.isArray(decoded.choices)).toBe(true);
52+
expect(decoded.choices).toHaveLength(2);
53+
54+
// Verify the first choice is decoded
55+
expect(decoded.choices[0].choice).toBe(
56+
"SELECT * FROM sales_detail LIMIT 100;",
57+
);
58+
expect(decoded.choices[0].feedback).toBe(
59+
"Correct! This query retrieves the first 100 rows from the sales_detail table using the LIMIT clause.",
60+
);
61+
62+
// Verify the second choice is decoded
63+
expect(decoded.choices[1].choice).toBe(
64+
"SELECT TOP 100 * FROM sales_detail;",
65+
);
66+
expect(decoded.choices[1].feedback).toBe(
67+
"Incorrect. The TOP clause is used in SQL Server, not in PostgreSQL.",
68+
);
69+
});
70+
71+
it("should handle the full questionVersions structure", () => {
72+
const apiResponse = {
73+
id: 1025,
74+
questionVersions: [
75+
{
76+
id: 2246,
77+
questionId: 6998,
78+
question:
79+
"PHA+V2hpY2ggU1FMIHF1ZXJ5IGNvcnJlY3RseSByZXRyaWV2ZXMgdGhlIGZpcnN0IDEwMCByb3dzIGZyb20gdGhlIHNhbGVzX2RldGFpbCB0YWJsZSBpbiBQb3N0Z3JlU1FMPzwvcD4=",
80+
choices:
81+
'[{"choice":"U0VMRUNUICogRlJPTSBzYWxlc19kZXRhaWwgTElNSVQgMTAwOw==","isCorrect":true,"points":2,"feedback":"Q29ycmVjdCEgVGhpcyBxdWVyeSByZXRyaWV2ZXMgdGhlIGZpcnN0IDEwMCByb3dzIGZyb20gdGhlIHNhbGVzX2RldGFpbCB0YWJsZSB1c2luZyB0aGUgTElNSVQgY2xhdXNlLg=="}]',
82+
},
83+
],
84+
};
85+
86+
const decoded = DataTransformer.decodeFromAPI(
87+
apiResponse,
88+
API_DECODE_CONFIG,
89+
);
90+
91+
console.log("\n=== QUESTION VERSIONS STRUCTURE ===");
92+
console.log(
93+
"Question:",
94+
decoded.questionVersions[0].question.substring(0, 50) + "...",
95+
);
96+
console.log("Choices:", decoded.questionVersions[0].choices);
97+
98+
expect(decoded.questionVersions[0].question).toContain(
99+
"Which SQL query correctly retrieves",
100+
);
101+
expect(Array.isArray(decoded.questionVersions[0].choices)).toBe(true);
102+
expect(decoded.questionVersions[0].choices[0].choice).toBe(
103+
"SELECT * FROM sales_detail LIMIT 100;",
104+
);
105+
expect(decoded.questionVersions[0].choices[0].feedback).toContain(
106+
"Correct! This query retrieves",
107+
);
108+
});
109+
});

0 commit comments

Comments
 (0)