From beb71f2e703b5395580de2da983cc5bdd144b8fc Mon Sep 17 00:00:00 2001 From: Jacob Maynard Date: Sat, 18 Apr 2026 10:27:14 -0500 Subject: [PATCH 1/3] add amstar2 types --- packages/shared/package.json | 7 ++ .../__tests__/amstar2-answers-schema.test.ts | 73 +++++++++++++++++++ .../src/checklists/amstar2/answers-schema.ts | 67 +++++++++++++++++ .../shared/src/checklists/amstar2/index.ts | 3 + .../useProject/checklists/handlers/amstar2.ts | 39 +++++----- .../useProject/checklists/handlers/base.ts | 6 +- .../primitives/useProject/checklists/index.ts | 15 +++- pnpm-lock.yaml | 4 + 8 files changed, 189 insertions(+), 25 deletions(-) create mode 100644 packages/shared/src/checklists/__tests__/amstar2-answers-schema.test.ts create mode 100644 packages/shared/src/checklists/amstar2/answers-schema.ts diff --git a/packages/shared/package.json b/packages/shared/package.json index 2fed951ab..928132b9f 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -24,6 +24,10 @@ "types": "./dist/checklists/index.d.ts", "import": "./dist/checklists/index.js" }, + "./checklists/amstar2": { + "types": "./dist/checklists/amstar2/index.d.ts", + "import": "./dist/checklists/amstar2/index.js" + }, "./checklists/rob2": { "types": "./dist/checklists/rob2/index.d.ts", "import": "./dist/checklists/rob2/index.js" @@ -41,5 +45,8 @@ "devDependencies": { "typescript": "^5.9.3", "vitest": "^4.1.3" + }, + "dependencies": { + "zod": "^4.3.6" } } diff --git a/packages/shared/src/checklists/__tests__/amstar2-answers-schema.test.ts b/packages/shared/src/checklists/__tests__/amstar2-answers-schema.test.ts new file mode 100644 index 000000000..279373d38 --- /dev/null +++ b/packages/shared/src/checklists/__tests__/amstar2-answers-schema.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from 'vitest'; +import { + Amstar2QuestionAnswerSchema, + AMSTAR2_KEY_SCHEMAS, + isAmstar2Key, +} from '../amstar2/answers-schema.js'; + +describe('Amstar2 answer-payload schemas', () => { + it('accepts a well-formed answer payload', () => { + const parsed = Amstar2QuestionAnswerSchema.parse({ + answers: [ + [true, false], + [false, true], + ], + critical: true, + }); + expect(parsed.answers).toHaveLength(2); + expect(parsed.critical).toBe(true); + }); + + it('allows critical to be omitted', () => { + const parsed = Amstar2QuestionAnswerSchema.parse({ + answers: [[true, false]], + }); + expect(parsed.critical).toBeUndefined(); + }); + + it('rejects non-boolean matrix entries', () => { + expect(() => + Amstar2QuestionAnswerSchema.parse({ + answers: [['oops' as unknown as boolean]], + }), + ).toThrow(); + }); + + it('rejects missing answers field', () => { + expect(() => + Amstar2QuestionAnswerSchema.parse({ critical: false } as unknown), + ).toThrow(); + }); + + it('isAmstar2Key narrows known and rejects unknown keys', () => { + expect(isAmstar2Key('q1')).toBe(true); + expect(isAmstar2Key('q9a')).toBe(true); + expect(isAmstar2Key('q11b')).toBe(true); + expect(isAmstar2Key('q17')).toBe(false); + expect(isAmstar2Key('notes')).toBe(false); + }); + + it('AMSTAR2_KEY_SCHEMAS covers every AMSTAR2 data key', () => { + const expectedKeys = [ + 'q1', + 'q2', + 'q3', + 'q4', + 'q5', + 'q6', + 'q7', + 'q8', + 'q9a', + 'q9b', + 'q10', + 'q11a', + 'q11b', + 'q12', + 'q13', + 'q14', + 'q15', + 'q16', + ]; + expect(Object.keys(AMSTAR2_KEY_SCHEMAS).sort()).toEqual(expectedKeys.sort()); + }); +}); diff --git a/packages/shared/src/checklists/amstar2/answers-schema.ts b/packages/shared/src/checklists/amstar2/answers-schema.ts new file mode 100644 index 000000000..11da8ffb1 --- /dev/null +++ b/packages/shared/src/checklists/amstar2/answers-schema.ts @@ -0,0 +1,67 @@ +/** + * AMSTAR2 answer-payload schemas. + * + * Runtime Zod schemas and derived types for the `data` passed to + * `handler.updateAnswer(answersMap, key, data)`. Distinct from the rendering + * schema in `schema.ts` (which describes question UI) and the broader + * interfaces in `../types.ts`. + */ + +import { z } from 'zod'; + +export const Amstar2QuestionAnswerSchema = z.object({ + answers: z.array(z.array(z.boolean())), + critical: z.boolean().optional(), +}); + +export type Amstar2QuestionAnswer = z.infer; + +export const Amstar2AnswersSchema = z.object({ + q1: Amstar2QuestionAnswerSchema, + q2: Amstar2QuestionAnswerSchema, + q3: Amstar2QuestionAnswerSchema, + q4: Amstar2QuestionAnswerSchema, + q5: Amstar2QuestionAnswerSchema, + q6: Amstar2QuestionAnswerSchema, + q7: Amstar2QuestionAnswerSchema, + q8: Amstar2QuestionAnswerSchema, + q9a: Amstar2QuestionAnswerSchema, + q9b: Amstar2QuestionAnswerSchema, + q10: Amstar2QuestionAnswerSchema, + q11a: Amstar2QuestionAnswerSchema, + q11b: Amstar2QuestionAnswerSchema, + q12: Amstar2QuestionAnswerSchema, + q13: Amstar2QuestionAnswerSchema, + q14: Amstar2QuestionAnswerSchema, + q15: Amstar2QuestionAnswerSchema, + q16: Amstar2QuestionAnswerSchema, +}); + +export type Amstar2Answers = z.infer; +export type Amstar2Key = keyof Amstar2Answers; +export type Amstar2AnswerFor = Amstar2Answers[K]; + +export const AMSTAR2_KEY_SCHEMAS: Record = { + q1: Amstar2QuestionAnswerSchema, + q2: Amstar2QuestionAnswerSchema, + q3: Amstar2QuestionAnswerSchema, + q4: Amstar2QuestionAnswerSchema, + q5: Amstar2QuestionAnswerSchema, + q6: Amstar2QuestionAnswerSchema, + q7: Amstar2QuestionAnswerSchema, + q8: Amstar2QuestionAnswerSchema, + q9a: Amstar2QuestionAnswerSchema, + q9b: Amstar2QuestionAnswerSchema, + q10: Amstar2QuestionAnswerSchema, + q11a: Amstar2QuestionAnswerSchema, + q11b: Amstar2QuestionAnswerSchema, + q12: Amstar2QuestionAnswerSchema, + q13: Amstar2QuestionAnswerSchema, + q14: Amstar2QuestionAnswerSchema, + q15: Amstar2QuestionAnswerSchema, + q16: Amstar2QuestionAnswerSchema, +}; + +export function isAmstar2Key(key: string): key is Amstar2Key { + return key in AMSTAR2_KEY_SCHEMAS; +} diff --git a/packages/shared/src/checklists/amstar2/index.ts b/packages/shared/src/checklists/amstar2/index.ts index 02afee88f..8eabde528 100644 --- a/packages/shared/src/checklists/amstar2/index.ts +++ b/packages/shared/src/checklists/amstar2/index.ts @@ -13,6 +13,9 @@ // Schema (checklist map and question definitions) export * from './schema.js'; +// Answer-payload schemas (Zod runtime validation for updateAnswer data) +export * from './answers-schema.js'; + // Checklist creation export * from './create.js'; diff --git a/packages/web/src/primitives/useProject/checklists/handlers/amstar2.ts b/packages/web/src/primitives/useProject/checklists/handlers/amstar2.ts index 73eb6e69b..6715f3654 100644 --- a/packages/web/src/primitives/useProject/checklists/handlers/amstar2.ts +++ b/packages/web/src/primitives/useProject/checklists/handlers/amstar2.ts @@ -3,13 +3,9 @@ */ import * as Y from 'yjs'; +import type { Amstar2Answers, Amstar2Key } from '@corates/shared/checklists/amstar2'; import { ChecklistHandler, type TextGetterFn } from './base'; -interface AMSTAR2QuestionData { - answers: boolean[][]; - critical: boolean; -} - export class AMSTAR2Handler extends ChecklistHandler { extractAnswersFromTemplate(template: Record): Record { const answersData: Record = {}; @@ -29,10 +25,10 @@ export class AMSTAR2Handler extends ChecklistHandler { const addedKeys = new Set(); Object.entries(answersData).forEach(([questionKey, questionData]) => { - const qd = questionData as AMSTAR2QuestionData; + const qd = questionData as Amstar2Answers[Amstar2Key]; const questionYMap = new Y.Map(); questionYMap.set('answers', qd.answers); - questionYMap.set('critical', qd.critical); + questionYMap.set('critical', qd.critical ?? false); if (!subQuestionPattern.test(questionKey)) { questionYMap.set('note', new Y.Text()); @@ -62,18 +58,23 @@ export class AMSTAR2Handler extends ChecklistHandler { return answers; } - updateAnswer(answersMap: Y.Map, key: string, data: Record): void { - if (data.answers !== undefined) { - let questionYMap = answersMap.get(key) as Y.Map | undefined; - if (!questionYMap || !(questionYMap instanceof Y.Map)) { - questionYMap = new Y.Map(); - answersMap.set(key, questionYMap); - } - questionYMap.set('answers', data.answers); - questionYMap.set('critical', data.critical); - if (!questionYMap.get('note')) { - questionYMap.set('note', new Y.Text()); - } + updateAnswer( + answersMap: Y.Map, + key: K, + data: Amstar2Answers[K], + ): void; + updateAnswer(answersMap: Y.Map, key: string, data: unknown): void; + updateAnswer(answersMap: Y.Map, key: string, data: unknown): void { + const typed = data as Amstar2Answers[Amstar2Key]; + let questionYMap = answersMap.get(key) as Y.Map | undefined; + if (!questionYMap || !(questionYMap instanceof Y.Map)) { + questionYMap = new Y.Map(); + answersMap.set(key, questionYMap); + } + questionYMap.set('answers', typed.answers); + questionYMap.set('critical', typed.critical ?? false); + if (!questionYMap.get('note')) { + questionYMap.set('note', new Y.Text()); } } diff --git a/packages/web/src/primitives/useProject/checklists/handlers/base.ts b/packages/web/src/primitives/useProject/checklists/handlers/base.ts index 1c5fff5cd..b94ffd2fd 100644 --- a/packages/web/src/primitives/useProject/checklists/handlers/base.ts +++ b/packages/web/src/primitives/useProject/checklists/handlers/base.ts @@ -17,11 +17,7 @@ export abstract class ChecklistHandler { abstract extractAnswersFromTemplate(template: Record): Record; abstract createAnswersYMap(answersData: Record): Y.Map; abstract serializeAnswers(answersMap: Y.Map): Record; - abstract updateAnswer( - answersMap: Y.Map, - key: string, - data: Record, - ): void; + abstract updateAnswer(answersMap: Y.Map, key: string, data: unknown): void; getTextGetter(_getYDoc: () => Y.Doc | null): TextGetterFn | null { return null; diff --git a/packages/web/src/primitives/useProject/checklists/index.ts b/packages/web/src/primitives/useProject/checklists/index.ts index cf4d06d5c..531774a01 100644 --- a/packages/web/src/primitives/useProject/checklists/index.ts +++ b/packages/web/src/primitives/useProject/checklists/index.ts @@ -4,6 +4,11 @@ */ import * as Y from 'yjs'; +import { + AMSTAR2_KEY_SCHEMAS, + isAmstar2Key, + type Amstar2Key, +} from '@corates/shared/checklists/amstar2'; import { createChecklistOfType, CHECKLIST_TYPES } from '@/checklist-registry'; import { CHECKLIST_STATUS } from '@/constants/checklist-status'; import { createCommonOperations } from './common'; @@ -262,7 +267,15 @@ export function createChecklistOperations( const handler = getHandler(checklistType); if (handler) { - handler.updateAnswer(answersMap, key, data); + if (checklistType === CHECKLIST_TYPES.AMSTAR2) { + if (!isAmstar2Key(key)) { + throw new Error(`[updateChecklistAnswer] Invalid AMSTAR2 key: ${key}`); + } + const parsed = AMSTAR2_KEY_SCHEMAS[key as Amstar2Key].parse(data); + (handler as AMSTAR2Handler).updateAnswer(answersMap, key, parsed); + } else { + handler.updateAnswer(answersMap, key, data); + } } else { answersMap.set(key, data); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d16d3e9bb..6275fdf2c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -68,6 +68,10 @@ importers: version: 2.0.17(mermaid@11.14.0)(vitepress@1.6.4(@algolia/client-search@5.49.1)(@types/node@25.5.2)(axios@1.13.6)(change-case@5.4.4)(lightningcss@1.32.0)(postcss@8.5.8)(search-insights@2.17.3)(terser@5.46.0)(typescript@6.0.2)) packages/shared: + dependencies: + zod: + specifier: ^4.3.6 + version: 4.3.6 devDependencies: typescript: specifier: ^5.9.3 From d44618b012036062830d8eeb9ece789da2fddb28 Mon Sep 17 00:00:00 2001 From: Jacob Maynard Date: Sat, 18 Apr 2026 10:39:35 -0500 Subject: [PATCH 2/3] migrate rob and robins to better types --- packages/shared/package.json | 4 + .../__tests__/rob2-answers-schema.test.ts | 89 +++++++++++++++ .../__tests__/robins-i-answers-schema.test.ts | 82 ++++++++++++++ .../src/checklists/rob2/answers-schema.ts | 62 +++++++++++ packages/shared/src/checklists/rob2/index.ts | 3 + .../src/checklists/robins-i/answers-schema.ts | 104 ++++++++++++++++++ .../shared/src/checklists/robins-i/index.ts | 3 + .../useProject/checklists/handlers/amstar2.ts | 9 +- .../useProject/checklists/handlers/base.ts | 1 - .../useProject/checklists/handlers/rob2.ts | 31 ++---- .../checklists/handlers/robins-i.ts | 36 +++--- .../primitives/useProject/checklists/index.ts | 40 ++++--- 12 files changed, 399 insertions(+), 65 deletions(-) create mode 100644 packages/shared/src/checklists/__tests__/rob2-answers-schema.test.ts create mode 100644 packages/shared/src/checklists/__tests__/robins-i-answers-schema.test.ts create mode 100644 packages/shared/src/checklists/rob2/answers-schema.ts create mode 100644 packages/shared/src/checklists/robins-i/answers-schema.ts diff --git a/packages/shared/package.json b/packages/shared/package.json index 928132b9f..4ffee8cf0 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -28,6 +28,10 @@ "types": "./dist/checklists/amstar2/index.d.ts", "import": "./dist/checklists/amstar2/index.js" }, + "./checklists/robins-i": { + "types": "./dist/checklists/robins-i/index.d.ts", + "import": "./dist/checklists/robins-i/index.js" + }, "./checklists/rob2": { "types": "./dist/checklists/rob2/index.d.ts", "import": "./dist/checklists/rob2/index.js" diff --git a/packages/shared/src/checklists/__tests__/rob2-answers-schema.test.ts b/packages/shared/src/checklists/__tests__/rob2-answers-schema.test.ts new file mode 100644 index 000000000..33a36e079 --- /dev/null +++ b/packages/shared/src/checklists/__tests__/rob2-answers-schema.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect } from 'vitest'; +import { + Rob2DomainUpdateSchema, + Rob2OverallUpdateSchema, + Rob2PreliminaryUpdateSchema, + ROB2_KEY_SCHEMAS, + isRob2Key, +} from '../rob2/answers-schema.js'; + +describe('RoB2 answer-payload schemas', () => { + it('accepts partial domain update (answers only)', () => { + const parsed = Rob2DomainUpdateSchema.parse({ + answers: { d1_1: { answer: 'Y' } }, + }); + expect(parsed.answers?.d1_1?.answer).toBe('Y'); + }); + + it('accepts full domain update', () => { + const parsed = Rob2DomainUpdateSchema.parse({ + judgement: 'Low', + direction: 'NA', + answers: { + d1_1: { answer: 'Y', comment: 'ok' }, + }, + }); + expect(parsed.judgement).toBe('Low'); + expect(parsed.direction).toBe('NA'); + }); + + it('rejects unknown keys on domain update (strict)', () => { + expect(() => + Rob2DomainUpdateSchema.parse({ judgement: 'Low', foo: 1 } as unknown), + ).toThrow(); + }); + + it('accepts overall update', () => { + const parsed = Rob2OverallUpdateSchema.parse({ judgement: 'High', direction: null }); + expect(parsed.judgement).toBe('High'); + expect(parsed.direction).toBeNull(); + }); + + it('accepts full preliminary payload including text fields', () => { + const parsed = Rob2PreliminaryUpdateSchema.parse({ + studyDesign: 'Individually-randomized parallel-group trial', + aim: 'ASSIGNMENT', + deviationsToAddress: [], + sources: { 'Journal article(s)': true }, + experimental: 'Drug X', + comparator: 'Placebo', + numericalResult: 'RR=1.5', + }); + expect(parsed.experimental).toBe('Drug X'); + expect(parsed.sources?.['Journal article(s)']).toBe(true); + }); + + it('accepts preliminary reset payload', () => { + const parsed = Rob2PreliminaryUpdateSchema.parse({ + studyDesign: null, + aim: null, + deviationsToAddress: [], + sources: {}, + experimental: '', + comparator: '', + numericalResult: '', + }); + expect(parsed.studyDesign).toBeNull(); + }); + + it('isRob2Key narrows known keys', () => { + expect(isRob2Key('preliminary')).toBe(true); + expect(isRob2Key('domain2b')).toBe(true); + expect(isRob2Key('overall')).toBe(true); + expect(isRob2Key('sectionB')).toBe(false); + }); + + it('ROB2_KEY_SCHEMAS covers every expected section', () => { + const expected = [ + 'preliminary', + 'domain1', + 'domain2a', + 'domain2b', + 'domain3', + 'domain4', + 'domain5', + 'overall', + ]; + expect(Object.keys(ROB2_KEY_SCHEMAS).sort()).toEqual(expected.sort()); + }); +}); diff --git a/packages/shared/src/checklists/__tests__/robins-i-answers-schema.test.ts b/packages/shared/src/checklists/__tests__/robins-i-answers-schema.test.ts new file mode 100644 index 000000000..104c1970b --- /dev/null +++ b/packages/shared/src/checklists/__tests__/robins-i-answers-schema.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect } from 'vitest'; +import { + RobinsIDomainUpdateSchema, + RobinsISectionBUpdateSchema, + RobinsISectionDUpdateSchema, + ROBINS_I_KEY_SCHEMAS, + isRobinsIKey, +} from '../robins-i/answers-schema.js'; + +describe('ROBINS-I answer-payload schemas', () => { + it('accepts partial domain updates (direction only)', () => { + const parsed = RobinsIDomainUpdateSchema.parse({ direction: 'Favours intervention' }); + expect(parsed.direction).toBe('Favours intervention'); + expect(parsed.judgement).toBeUndefined(); + }); + + it('accepts full domain update with answers record', () => { + const parsed = RobinsIDomainUpdateSchema.parse({ + judgement: 'Moderate', + direction: null, + answers: { + d1_1: { answer: 'Y', comment: 'rationale' }, + d1_2: { answer: null }, + }, + }); + expect(parsed.answers?.d1_1?.answer).toBe('Y'); + expect(parsed.answers?.d1_2?.answer).toBeNull(); + }); + + it('rejects unknown keys on domain update (strict)', () => { + expect(() => + RobinsIDomainUpdateSchema.parse({ judgement: 'Low', bogus: true } as unknown), + ).toThrow(); + }); + + it('accepts sectionB partial (b1 only)', () => { + const parsed = RobinsISectionBUpdateSchema.parse({ b1: { answer: 'Y' } }); + expect(parsed.b1?.answer).toBe('Y'); + }); + + it('accepts sectionB with stopAssessment flag', () => { + const parsed = RobinsISectionBUpdateSchema.parse({ stopAssessment: true }); + expect(parsed.stopAssessment).toBe(true); + }); + + it('accepts sectionD partial', () => { + const parsed = RobinsISectionDUpdateSchema.parse({ + sources: { 'Journal article(s)': true }, + otherSpecify: 'extra', + }); + expect(parsed.sources?.['Journal article(s)']).toBe(true); + expect(parsed.otherSpecify).toBe('extra'); + }); + + it('isRobinsIKey accepts known section keys', () => { + expect(isRobinsIKey('sectionB')).toBe(true); + expect(isRobinsIKey('domain1a')).toBe(true); + expect(isRobinsIKey('domain6')).toBe(true); + expect(isRobinsIKey('overall')).toBe(true); + expect(isRobinsIKey('garbage')).toBe(false); + }); + + it('ROBINS_I_KEY_SCHEMAS covers every expected section', () => { + const expected = [ + 'planning', + 'sectionA', + 'sectionB', + 'sectionC', + 'sectionD', + 'confoundingEvaluation', + 'domain1a', + 'domain1b', + 'domain2', + 'domain3', + 'domain4', + 'domain5', + 'domain6', + 'overall', + ]; + expect(Object.keys(ROBINS_I_KEY_SCHEMAS).sort()).toEqual(expected.sort()); + }); +}); diff --git a/packages/shared/src/checklists/rob2/answers-schema.ts b/packages/shared/src/checklists/rob2/answers-schema.ts new file mode 100644 index 000000000..a53bae027 --- /dev/null +++ b/packages/shared/src/checklists/rob2/answers-schema.ts @@ -0,0 +1,62 @@ +/** + * RoB2 answer-payload schemas. + * + * Runtime Zod schemas and derived types for the `data` passed to + * `handler.updateAnswer(answersMap, key, data)`. Distinct from the rendering + * schema in `schema.ts` and the broader interfaces in `../types.ts`. + */ + +import { z } from 'zod'; + +const QuestionEntrySchema = z.object({ + answer: z.string().nullable().optional(), + comment: z.string().optional(), +}); + +export const Rob2DomainUpdateSchema = z + .object({ + judgement: z.string().nullable().optional(), + direction: z.string().nullable().optional(), + answers: z.record(z.string(), QuestionEntrySchema).optional(), + }) + .strict(); + +export const Rob2OverallUpdateSchema = z + .object({ + judgement: z.string().nullable().optional(), + direction: z.string().nullable().optional(), + }) + .strict(); + +export const Rob2PreliminaryUpdateSchema = z + .object({ + studyDesign: z.string().nullable().optional(), + experimental: z.string().optional(), + comparator: z.string().optional(), + numericalResult: z.string().optional(), + aim: z.string().nullable().optional(), + deviationsToAddress: z.array(z.string()).optional(), + sources: z.record(z.string(), z.boolean()).optional(), + }) + .strict(); + +export const ROB2_KEY_SCHEMAS = { + preliminary: Rob2PreliminaryUpdateSchema, + domain1: Rob2DomainUpdateSchema, + domain2a: Rob2DomainUpdateSchema, + domain2b: Rob2DomainUpdateSchema, + domain3: Rob2DomainUpdateSchema, + domain4: Rob2DomainUpdateSchema, + domain5: Rob2DomainUpdateSchema, + overall: Rob2OverallUpdateSchema, +} as const; + +export type Rob2Answers = { + [K in keyof typeof ROB2_KEY_SCHEMAS]: z.infer<(typeof ROB2_KEY_SCHEMAS)[K]>; +}; +export type Rob2Key = keyof Rob2Answers; +export type Rob2AnswerFor = Rob2Answers[K]; + +export function isRob2Key(key: string): key is Rob2Key { + return key in ROB2_KEY_SCHEMAS; +} diff --git a/packages/shared/src/checklists/rob2/index.ts b/packages/shared/src/checklists/rob2/index.ts index 97c8fad9d..e3133be71 100644 --- a/packages/shared/src/checklists/rob2/index.ts +++ b/packages/shared/src/checklists/rob2/index.ts @@ -19,3 +19,6 @@ export * from './answers.js'; // Comparison utilities export * from './compare.js'; + +// Answer-payload schemas (Zod runtime validation for updateAnswer data) +export * from './answers-schema.js'; diff --git a/packages/shared/src/checklists/robins-i/answers-schema.ts b/packages/shared/src/checklists/robins-i/answers-schema.ts new file mode 100644 index 000000000..3aaa2b783 --- /dev/null +++ b/packages/shared/src/checklists/robins-i/answers-schema.ts @@ -0,0 +1,104 @@ +/** + * ROBINS-I answer-payload schemas. + * + * Runtime Zod schemas and derived types for the `data` passed to + * `handler.updateAnswer(answersMap, key, data)`. Distinct from the rendering + * schema in `schema.ts` and the broader interfaces in `../types.ts`. + */ + +import { z } from 'zod'; + +const QuestionEntrySchema = z.object({ + answer: z.string().nullable().optional(), + comment: z.string().optional(), +}); + +export const RobinsIDomainUpdateSchema = z + .object({ + judgement: z.string().nullable().optional(), + judgementSource: z.string().optional(), + direction: z.string().nullable().optional(), + answers: z.record(z.string(), QuestionEntrySchema).optional(), + }) + .strict(); + +export const RobinsIOverallUpdateSchema = z + .object({ + judgement: z.string().nullable().optional(), + judgementSource: z.string().optional(), + direction: z.string().nullable().optional(), + }) + .strict(); + +export const RobinsISectionBUpdateSchema = z + .object({ + b1: QuestionEntrySchema.optional(), + b2: QuestionEntrySchema.optional(), + b3: QuestionEntrySchema.optional(), + stopAssessment: z.boolean().optional(), + }) + .strict(); + +export const RobinsISectionDUpdateSchema = z + .object({ + sources: z.record(z.string(), z.boolean()).optional(), + otherSpecify: z.string().optional(), + }) + .strict(); + +export const RobinsIConfoundingEvaluationUpdateSchema = z + .object({ + predefined: z.array(z.unknown()).optional(), + additional: z.array(z.unknown()).optional(), + }) + .strict(); + +export const RobinsIPlanningUpdateSchema = z + .object({ + confoundingFactors: z.string().optional(), + }) + .strict(); + +export const RobinsISectionAUpdateSchema = z + .object({ + numericalResult: z.string().optional(), + furtherDetails: z.string().optional(), + outcome: z.string().optional(), + }) + .strict(); + +export const RobinsISectionCUpdateSchema = z + .object({ + isPerProtocol: z.boolean().optional(), + participants: z.string().optional(), + interventionStrategy: z.string().optional(), + comparatorStrategy: z.string().optional(), + }) + .strict(); + +export const ROBINS_I_KEY_SCHEMAS = { + planning: RobinsIPlanningUpdateSchema, + sectionA: RobinsISectionAUpdateSchema, + sectionB: RobinsISectionBUpdateSchema, + sectionC: RobinsISectionCUpdateSchema, + sectionD: RobinsISectionDUpdateSchema, + confoundingEvaluation: RobinsIConfoundingEvaluationUpdateSchema, + domain1a: RobinsIDomainUpdateSchema, + domain1b: RobinsIDomainUpdateSchema, + domain2: RobinsIDomainUpdateSchema, + domain3: RobinsIDomainUpdateSchema, + domain4: RobinsIDomainUpdateSchema, + domain5: RobinsIDomainUpdateSchema, + domain6: RobinsIDomainUpdateSchema, + overall: RobinsIOverallUpdateSchema, +} as const; + +export type RobinsIAnswers = { + [K in keyof typeof ROBINS_I_KEY_SCHEMAS]: z.infer<(typeof ROBINS_I_KEY_SCHEMAS)[K]>; +}; +export type RobinsIKey = keyof RobinsIAnswers; +export type RobinsIAnswerFor = RobinsIAnswers[K]; + +export function isRobinsIKey(key: string): key is RobinsIKey { + return key in ROBINS_I_KEY_SCHEMAS; +} diff --git a/packages/shared/src/checklists/robins-i/index.ts b/packages/shared/src/checklists/robins-i/index.ts index 93b81015b..f7ce811b5 100644 --- a/packages/shared/src/checklists/robins-i/index.ts +++ b/packages/shared/src/checklists/robins-i/index.ts @@ -16,3 +16,6 @@ export * from './create.js'; // Answer manipulation export * from './answers.js'; + +// Answer-payload schemas (Zod runtime validation for updateAnswer data) +export * from './answers-schema.js'; diff --git a/packages/web/src/primitives/useProject/checklists/handlers/amstar2.ts b/packages/web/src/primitives/useProject/checklists/handlers/amstar2.ts index 6715f3654..2802a80ea 100644 --- a/packages/web/src/primitives/useProject/checklists/handlers/amstar2.ts +++ b/packages/web/src/primitives/useProject/checklists/handlers/amstar2.ts @@ -62,17 +62,14 @@ export class AMSTAR2Handler extends ChecklistHandler { answersMap: Y.Map, key: K, data: Amstar2Answers[K], - ): void; - updateAnswer(answersMap: Y.Map, key: string, data: unknown): void; - updateAnswer(answersMap: Y.Map, key: string, data: unknown): void { - const typed = data as Amstar2Answers[Amstar2Key]; + ): void { let questionYMap = answersMap.get(key) as Y.Map | undefined; if (!questionYMap || !(questionYMap instanceof Y.Map)) { questionYMap = new Y.Map(); answersMap.set(key, questionYMap); } - questionYMap.set('answers', typed.answers); - questionYMap.set('critical', typed.critical ?? false); + questionYMap.set('answers', data.answers); + questionYMap.set('critical', data.critical ?? false); if (!questionYMap.get('note')) { questionYMap.set('note', new Y.Text()); } diff --git a/packages/web/src/primitives/useProject/checklists/handlers/base.ts b/packages/web/src/primitives/useProject/checklists/handlers/base.ts index b94ffd2fd..6d7c8fa06 100644 --- a/packages/web/src/primitives/useProject/checklists/handlers/base.ts +++ b/packages/web/src/primitives/useProject/checklists/handlers/base.ts @@ -17,7 +17,6 @@ export abstract class ChecklistHandler { abstract extractAnswersFromTemplate(template: Record): Record; abstract createAnswersYMap(answersData: Record): Y.Map; abstract serializeAnswers(answersMap: Y.Map): Record; - abstract updateAnswer(answersMap: Y.Map, key: string, data: unknown): void; getTextGetter(_getYDoc: () => Y.Doc | null): TextGetterFn | null { return null; diff --git a/packages/web/src/primitives/useProject/checklists/handlers/rob2.ts b/packages/web/src/primitives/useProject/checklists/handlers/rob2.ts index 259ac76df..1fcb892c1 100644 --- a/packages/web/src/primitives/useProject/checklists/handlers/rob2.ts +++ b/packages/web/src/primitives/useProject/checklists/handlers/rob2.ts @@ -3,6 +3,7 @@ */ import * as Y from 'yjs'; +import type { Rob2Answers, Rob2Key } from '@corates/shared/checklists/rob2'; import { ChecklistHandler, yTextToString, type TextGetterFn } from './base'; interface ROB2DomainTemplate { @@ -18,22 +19,6 @@ interface ROB2PreliminaryTemplate { sources?: Record; } -interface ROB2DomainUpdate { - judgement?: string | null; - direction?: string | null; - answers?: Record; -} - -interface ROB2PreliminaryUpdate { - studyDesign?: string | null; - aim?: string | null; - deviationsToAddress?: string[]; - sources?: Record; - experimental?: string; - comparator?: string; - numericalResult?: string; -} - export class ROB2Handler extends ChecklistHandler { extractAnswersFromTemplate(template: Record): Record { const answersData: Record = {}; @@ -175,7 +160,11 @@ export class ROB2Handler extends ChecklistHandler { return answers; } - updateAnswer(answersMap: Y.Map, key: string, data: Record): void { + updateAnswer( + answersMap: Y.Map, + key: K, + data: Rob2Answers[K], + ): void { const doc = answersMap.doc!; doc.transact(() => { let sectionYMap = answersMap.get(key) as Y.Map | undefined; @@ -186,7 +175,7 @@ export class ROB2Handler extends ChecklistHandler { } if (key.startsWith('domain') || key === 'overall') { - const domainData = data as ROB2DomainUpdate; + const domainData = data as Rob2Answers['domain1']; if (domainData.judgement !== undefined) { sectionYMap.set('judgement', domainData.judgement); } @@ -211,7 +200,7 @@ export class ROB2Handler extends ChecklistHandler { }); } } else if (key === 'preliminary') { - const prelimData = data as ROB2PreliminaryUpdate; + const prelimData = data as Rob2Answers['preliminary']; if (prelimData.studyDesign !== undefined) sectionYMap.set('studyDesign', prelimData.studyDesign); if (prelimData.aim !== undefined) sectionYMap.set('aim', prelimData.aim); @@ -219,10 +208,6 @@ export class ROB2Handler extends ChecklistHandler { sectionYMap.set('deviationsToAddress', prelimData.deviationsToAddress); if (prelimData.sources !== undefined) sectionYMap.set('sources', prelimData.sources); // NoteEditor manages Y.Text fields (experimental, comparator, numericalResult) - } else { - Object.entries(data).forEach(([fieldKey, fieldValue]) => { - sectionYMap!.set(fieldKey, fieldValue); - }); } }); } diff --git a/packages/web/src/primitives/useProject/checklists/handlers/robins-i.ts b/packages/web/src/primitives/useProject/checklists/handlers/robins-i.ts index 18239d45f..1285c2573 100644 --- a/packages/web/src/primitives/useProject/checklists/handlers/robins-i.ts +++ b/packages/web/src/primitives/useProject/checklists/handlers/robins-i.ts @@ -3,6 +3,7 @@ */ import * as Y from 'yjs'; +import type { RobinsIAnswers, RobinsIKey } from '@corates/shared/checklists/robins-i'; import { ChecklistHandler, yTextToString, type TextGetterFn } from './base'; interface ROBINSDomainTemplate { @@ -12,18 +13,6 @@ interface ROBINSDomainTemplate { answers?: Record; } -interface ROBINSDomainUpdate { - judgement?: string | null; - judgementSource?: string | null; - direction?: string | null; - answers?: Record; -} - -interface SectionBQuestion { - answer?: string | null; - comment?: string; -} - export class ROBINSIHandler extends ChecklistHandler { extractAnswersFromTemplate(template: Record): Record { const answersData: Record = {}; @@ -197,7 +186,11 @@ export class ROBINSIHandler extends ChecklistHandler { return answers; } - updateAnswer(answersMap: Y.Map, key: string, data: Record): void { + updateAnswer( + answersMap: Y.Map, + key: K, + data: RobinsIAnswers[K], + ): void { const doc = answersMap.doc!; doc.transact(() => { let sectionYMap = answersMap.get(key) as Y.Map | undefined; @@ -208,7 +201,7 @@ export class ROBINSIHandler extends ChecklistHandler { } if (key.startsWith('domain') || key === 'overall') { - const domainData = data as ROBINSDomainUpdate; + const domainData = data as RobinsIAnswers['domain1a']; if (domainData.judgement !== undefined) { sectionYMap.set('judgement', domainData.judgement); } @@ -238,30 +231,31 @@ export class ROBINSIHandler extends ChecklistHandler { }); } } else if (key === 'sectionB') { - Object.entries(data).forEach(([subKey, subValue]) => { + const sb = data as RobinsIAnswers['sectionB']; + Object.entries(sb).forEach(([subKey, subValue]) => { if (typeof subValue === 'object' && subValue !== null) { - const q = subValue as SectionBQuestion; let questionYMap = sectionYMap!.get(subKey) as Y.Map | undefined; if (!questionYMap || !(questionYMap instanceof Y.Map)) { questionYMap = new Y.Map(); sectionYMap!.set(subKey, questionYMap); } - if (q.answer !== undefined) questionYMap.set('answer', q.answer); - if (q.comment !== undefined) this.setYTextField(questionYMap, 'comment', q.comment); + if (subValue.answer !== undefined) questionYMap.set('answer', subValue.answer); + if (subValue.comment !== undefined) + this.setYTextField(questionYMap, 'comment', subValue.comment); } else { sectionYMap!.set(subKey, subValue); } }); } else if (key === 'confoundingEvaluation') { - const ce = data as { predefined?: unknown[]; additional?: unknown[] }; + const ce = data as RobinsIAnswers['confoundingEvaluation']; if (ce.predefined !== undefined) sectionYMap.set('predefined', ce.predefined); if (ce.additional !== undefined) sectionYMap.set('additional', ce.additional); } else if (key === 'sectionD') { - const sd = data as { sources?: Record; otherSpecify?: string }; + const sd = data as RobinsIAnswers['sectionD']; if (sd.sources !== undefined) sectionYMap.set('sources', sd.sources); if (sd.otherSpecify !== undefined) sectionYMap.set('otherSpecify', sd.otherSpecify); } else { - Object.entries(data).forEach(([fieldKey, fieldValue]) => { + Object.entries(data as Record).forEach(([fieldKey, fieldValue]) => { sectionYMap!.set(fieldKey, fieldValue); }); } diff --git a/packages/web/src/primitives/useProject/checklists/index.ts b/packages/web/src/primitives/useProject/checklists/index.ts index 531774a01..f61263b87 100644 --- a/packages/web/src/primitives/useProject/checklists/index.ts +++ b/packages/web/src/primitives/useProject/checklists/index.ts @@ -4,11 +4,9 @@ */ import * as Y from 'yjs'; -import { - AMSTAR2_KEY_SCHEMAS, - isAmstar2Key, - type Amstar2Key, -} from '@corates/shared/checklists/amstar2'; +import { AMSTAR2_KEY_SCHEMAS, isAmstar2Key } from '@corates/shared/checklists/amstar2'; +import { ROBINS_I_KEY_SCHEMAS, isRobinsIKey } from '@corates/shared/checklists/robins-i'; +import { ROB2_KEY_SCHEMAS, isRob2Key } from '@corates/shared/checklists/rob2'; import { createChecklistOfType, CHECKLIST_TYPES } from '@/checklist-registry'; import { CHECKLIST_STATUS } from '@/constants/checklist-status'; import { createCommonOperations } from './common'; @@ -265,19 +263,33 @@ export function createChecklistOperations( checklistYMap.set('answers', answersMap); } - const handler = getHandler(checklistType); - if (handler) { - if (checklistType === CHECKLIST_TYPES.AMSTAR2) { + switch (checklistType) { + case CHECKLIST_TYPES.AMSTAR2: { if (!isAmstar2Key(key)) { throw new Error(`[updateChecklistAnswer] Invalid AMSTAR2 key: ${key}`); } - const parsed = AMSTAR2_KEY_SCHEMAS[key as Amstar2Key].parse(data); - (handler as AMSTAR2Handler).updateAnswer(answersMap, key, parsed); - } else { - handler.updateAnswer(answersMap, key, data); + const parsed = AMSTAR2_KEY_SCHEMAS[key].parse(data); + amstar2Handler.updateAnswer(answersMap, key, parsed); + break; + } + case CHECKLIST_TYPES.ROBINS_I: { + if (!isRobinsIKey(key)) { + throw new Error(`[updateChecklistAnswer] Invalid ROBINS-I key: ${key}`); + } + const parsed = ROBINS_I_KEY_SCHEMAS[key].parse(data); + robinsIHandler.updateAnswer(answersMap, key, parsed); + break; + } + case CHECKLIST_TYPES.ROB2: { + if (!isRob2Key(key)) { + throw new Error(`[updateChecklistAnswer] Invalid ROB2 key: ${key}`); + } + const parsed = ROB2_KEY_SCHEMAS[key].parse(data); + rob2Handler.updateAnswer(answersMap, key, parsed); + break; } - } else { - answersMap.set(key, data); + default: + throw new Error(`[updateChecklistAnswer] Unknown checklist type: ${checklistType}`); } const currentStatus = checklistYMap.get('status'); From c62194d4862494b335cd66ef37ea8898cacb3d20 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Sat, 18 Apr 2026 15:40:22 +0000 Subject: [PATCH 3/3] Apply Prettier formatting --- .../src/checklists/__tests__/amstar2-answers-schema.test.ts | 4 +--- .../src/checklists/__tests__/rob2-answers-schema.test.ts | 4 +--- .../src/primitives/useProject/checklists/handlers/rob2.ts | 6 +----- 3 files changed, 3 insertions(+), 11 deletions(-) diff --git a/packages/shared/src/checklists/__tests__/amstar2-answers-schema.test.ts b/packages/shared/src/checklists/__tests__/amstar2-answers-schema.test.ts index 279373d38..3f65d0f8b 100644 --- a/packages/shared/src/checklists/__tests__/amstar2-answers-schema.test.ts +++ b/packages/shared/src/checklists/__tests__/amstar2-answers-schema.test.ts @@ -34,9 +34,7 @@ describe('Amstar2 answer-payload schemas', () => { }); it('rejects missing answers field', () => { - expect(() => - Amstar2QuestionAnswerSchema.parse({ critical: false } as unknown), - ).toThrow(); + expect(() => Amstar2QuestionAnswerSchema.parse({ critical: false } as unknown)).toThrow(); }); it('isAmstar2Key narrows known and rejects unknown keys', () => { diff --git a/packages/shared/src/checklists/__tests__/rob2-answers-schema.test.ts b/packages/shared/src/checklists/__tests__/rob2-answers-schema.test.ts index 33a36e079..422d4fdec 100644 --- a/packages/shared/src/checklists/__tests__/rob2-answers-schema.test.ts +++ b/packages/shared/src/checklists/__tests__/rob2-answers-schema.test.ts @@ -28,9 +28,7 @@ describe('RoB2 answer-payload schemas', () => { }); it('rejects unknown keys on domain update (strict)', () => { - expect(() => - Rob2DomainUpdateSchema.parse({ judgement: 'Low', foo: 1 } as unknown), - ).toThrow(); + expect(() => Rob2DomainUpdateSchema.parse({ judgement: 'Low', foo: 1 } as unknown)).toThrow(); }); it('accepts overall update', () => { diff --git a/packages/web/src/primitives/useProject/checklists/handlers/rob2.ts b/packages/web/src/primitives/useProject/checklists/handlers/rob2.ts index 1fcb892c1..e6b28ca59 100644 --- a/packages/web/src/primitives/useProject/checklists/handlers/rob2.ts +++ b/packages/web/src/primitives/useProject/checklists/handlers/rob2.ts @@ -160,11 +160,7 @@ export class ROB2Handler extends ChecklistHandler { return answers; } - updateAnswer( - answersMap: Y.Map, - key: K, - data: Rob2Answers[K], - ): void { + updateAnswer(answersMap: Y.Map, key: K, data: Rob2Answers[K]): void { const doc = answersMap.doc!; doc.transact(() => { let sectionYMap = answersMap.get(key) as Y.Map | undefined;