Skip to content

Commit 2c1b596

Browse files
committed
Disable personal info copying and add checks to backend
1 parent 280e13d commit 2c1b596

12 files changed

Lines changed: 233 additions & 15 deletions

File tree

client/src/components/admin/EditMapSubQuestions.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { SurveyMapQuestion, SurveyMapSubQuestion } from '@interfaces/survey';
22
import React from 'react';
3-
import SurveySectionAccordion from './SurveySectionAccordion/SurveySectionAccordion';
43
import { DndWrapper } from '../DragAndDrop/DndWrapper';
4+
import SurveySectionAccordion from './SurveySectionAccordion/SurveySectionAccordion';
55

66
interface Props {
77
mapQuestion: SurveyMapQuestion;
@@ -26,7 +26,7 @@ export default function EditMapSubQuestions(props: Props) {
2626
renderElement: (isDragging) => (
2727
<SurveySectionAccordion
2828
isDragging={isDragging}
29-
disableSectionCopying
29+
copyingSettings={{ copyingDisabled: true }}
3030
index={index}
3131
key={index}
3232
sx={styles.accordion}

client/src/components/admin/SurveySectionAccordion/SurveySectionAccordion.tsx

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ import {
4747
Typography,
4848
} from '@mui/material';
4949

50+
import { DragHandle } from '@src/components/DragAndDrop/SortableItem';
51+
import { CategorizedCheckboxIcon } from '@src/components/icons/CategorizedCheckboxIcon';
5052
import { useClipboard } from '@src/stores/ClipboardContext';
5153
import { useToasts } from '@src/stores/ToastContext';
5254
import { useTranslations } from '@src/stores/TranslationContext';
@@ -63,6 +65,7 @@ import React, {
6365
} from 'react';
6466
import ConfirmDialog from '../../ConfirmDialog';
6567
import EditAttachmentSection from '../EditAttachmentSection';
68+
import EditCategorizedCheckBoxQuestion from '../EditCategorizedCheckBoxQuestion/EditCategorizedCheckBoxQuestion';
6669
import EditCheckBoxQuestion from '../EditCheckBoxQuestion';
6770
import EditDocumentSection from '../EditDocumentSection';
6871
import EditFreeTextQuestion from '../EditFreeTextQuestion';
@@ -72,15 +75,12 @@ import EditMapQuestion from '../EditMapQuestion';
7275
import EditMatrixQuestion from '../EditMatrixQuestion';
7376
import { EditMultiMatrixQuestion } from '../EditMultiMatrixQuestion';
7477
import EditNumericQuestion from '../EditNumericQuestion';
78+
import { EditPersonalInfoQuestion } from '../EditPersonalInfoQuestion';
7579
import EditRadioQuestion from '../EditRadioQuestion';
7680
import EditSliderQuestion from '../EditSliderQuestion';
7781
import EditSortingQuestion from '../EditSortingQuestion';
7882
import EditTextSection from '../EditTextSection';
7983
import { SectionDetails } from './SectionDetails';
80-
import { DragHandle } from '@src/components/DragAndDrop/SortableItem';
81-
import { EditPersonalInfoQuestion } from '../EditPersonalInfoQuestion';
82-
import EditCategorizedCheckBoxQuestion from '../EditCategorizedCheckBoxQuestion/EditCategorizedCheckBoxQuestion';
83-
import { CategorizedCheckboxIcon } from '@src/components/icons/CategorizedCheckboxIcon';
8484

8585
const styles = {
8686
accordion: {
@@ -108,6 +108,11 @@ const styles = {
108108
},
109109
};
110110

111+
interface CopyingSettings {
112+
copyingDisabled?: boolean;
113+
disabledTooltip?: string;
114+
}
115+
111116
interface Props {
112117
index: number;
113118
section: SurveyPageSection;
@@ -118,7 +123,7 @@ interface Props {
118123
name: string;
119124
onEdit: (index: number, section: SurveyPageSection) => void;
120125
onDelete: (index: number) => void;
121-
disableSectionCopying?: boolean;
126+
copyingSettings?: CopyingSettings;
122127
pageId?: number;
123128
isDragging?: boolean;
124129
sx?: SxProps;
@@ -357,7 +362,13 @@ export default function SurveySectionAccordion(props: Props) {
357362
<em>{tr.EditSurveyPage.untitledSection}</em>
358363
)}
359364
</Typography>
360-
{!props.disableSectionCopying && (
365+
{props.copyingSettings.copyingDisabled &&
366+
props.copyingSettings.disabledTooltip && (
367+
<Tooltip title={props.copyingSettings.disabledTooltip}>
368+
<ContentCopy htmlColor={'disabled'} />
369+
</Tooltip>
370+
)}
371+
{!props.copyingSettings.copyingDisabled && (
361372
<IconButton
362373
onClick={async (event) => {
363374
event.stopPropagation();

client/src/components/admin/SurveySections.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { SurveyPage } from '@interfaces/survey';
22
import { useSurvey } from '@src/stores/SurveyContext';
3+
import { useTranslations } from '@src/stores/TranslationContext';
34
import { isFollowUpSectionParentType } from '@src/utils/typeCheck';
45
import React from 'react';
6+
import { DndWrapper } from '../DragAndDrop/DndWrapper';
57
import { FollowUpSections } from './SurveySectionAccordion/FollowUpSections';
68
import SurveySectionAccordion from './SurveySectionAccordion/SurveySectionAccordion';
7-
import { DndWrapper } from '../DragAndDrop/DndWrapper';
89

910
interface Props {
1011
page: SurveyPage;
@@ -15,6 +16,7 @@ interface Props {
1516

1617
export default function SurveySections(props: Props) {
1718
const { editSection, deleteSection, moveSection } = useSurvey();
19+
const { tr } = useTranslations();
1820

1921
return (
2022
<div>
@@ -51,6 +53,15 @@ export default function SurveySections(props: Props) {
5153
// Reset expanded section to null
5254
props.onExpandedSectionChange(null);
5355
}}
56+
copyingSettings={{
57+
copyingDisabled:
58+
section.type === 'personal-info' ||
59+
section.followUpSections?.some(
60+
(s) => s.type === 'personal-info',
61+
),
62+
disabledTooltip:
63+
tr.SurveySections.personalInfoFollowUpDisablesCopying,
64+
}}
5465
/>
5566
{isFollowUpSectionParentType(section) && (
5667
<FollowUpSections

client/src/stores/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,7 @@
268268
"noSelection": "No selection"
269269
},
270270
"SurveySections": {
271+
"personalInfoFollowUpDisablesCopying": "Personal information question as a follow-up question prevents copying.",
271272
"limitAnswers": "Limit the number of answers",
272273
"minAnswers": "Minimum number of answers",
273274
"maxAnswers": "Maximum number of answers",

client/src/stores/fi.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,7 @@
282282
"noSelection": "Ei valintaa"
283283
},
284284
"SurveySections": {
285+
"personalInfoFollowUpDisablesCopying": "Jatkokysymyksenä oleva henkilötietokysymys estää kopioinnin.",
285286
"limitAnswers": "Rajoita vastauslukumäärää",
286287
"minAnswers": "Vastauksia vähintään",
287288
"maxAnswers": "Vastauksia enintään",

server/jest.config.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
11
{
22
"preset": "ts-jest",
3-
"testEnvironment": "node"
3+
"testEnvironment": "node",
4+
"moduleNameMapper": {
5+
"^@src/(.*)$": "<rootDir>/src/$1",
6+
"^@interfaces/(.*)$": "<rootDir>/../interfaces/$1",
7+
"^@clientSrc/(.*)$": "<rootDir>/../client/src/$1",
8+
"^@tests/(.*)$": "<rootDir>/src/tests/$1",
9+
"^@__mocks__/(.*)$": "<rootDir>/src/tests/__mocks__/$1"
10+
}
411
}

server/src/__mocks__/database.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import PgPromise from 'pg-promise';
2+
3+
// Mock database object with methods that return promises
4+
const mockDb = {
5+
one: jest.fn().mockResolvedValue({}),
6+
none: jest.fn().mockResolvedValue(undefined),
7+
manyOrNone: jest.fn().mockResolvedValue([]),
8+
oneOrNone: jest.fn().mockResolvedValue(null),
9+
any: jest.fn().mockResolvedValue([]),
10+
tx: jest.fn((callback) => callback(mockDb)),
11+
};
12+
13+
export const getDb = jest.fn(() => mockDb);
14+
export const mockDb_ = mockDb;
15+
16+
export const getColumnSet = jest.fn(
17+
(tableName: string, columns: any[]) =>
18+
new (PgPromise().helpers.ColumnSet)(columns, {
19+
table: { table: tableName, schema: 'data' },
20+
}),
21+
);
22+
23+
export const getGeoJSONColumn = jest.fn((name: string) => ({
24+
name,
25+
mod: ':raw',
26+
init: () => 'NULL',
27+
}));
28+
29+
export const getMultiInsertQuery = jest.fn(() => '');
30+
export const getMultiUpdateQuery = jest.fn(() => '');
31+
export const encryptionKey = 'test-encryption-key';

server/src/__mocks__/logger.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export default {
2+
info: jest.fn(),
3+
warn: jest.fn(),
4+
error: jest.fn(),
5+
debug: jest.fn(),
6+
};
Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,53 @@
1+
import { Survey } from '@interfaces/survey';
2+
import {
3+
createMockPersonalInfoQuestion,
4+
createMockSurvey,
5+
} from '@tests/data/survey';
6+
import { BadRequestError } from '../error';
7+
8+
// Mock the database module before importing survey
9+
jest.mock('@src/database');
10+
jest.mock('@src/logger');
11+
12+
// Import after mocking
13+
import { getDb } from '@src/database';
14+
import { updateSurvey } from './survey';
15+
116
describe('Survey', () => {
2-
// TODO: replace with an actual test case
3-
it('test dummy', async () => {
4-
expect(true).toEqual(true);
17+
beforeEach(() => {
18+
jest.clearAllMocks();
19+
const mockDb = getDb() as jest.Mocked<ReturnType<typeof getDb>>;
20+
21+
// Mock the transaction to execute the callback
22+
mockDb.tx.mockImplementation(async (callback: any) => {
23+
return callback(mockDb);
24+
});
25+
});
26+
27+
describe('updateSurvey', () => {
28+
it('should throw BadRequestError when trying to add two personal info questions', async () => {
29+
const survey = createMockSurvey(1, 100);
30+
31+
const surveyWithTwoPersonalInfoQuestions: Survey = {
32+
...survey,
33+
pages: [
34+
{
35+
...survey.pages![0],
36+
sections: [
37+
createMockPersonalInfoQuestion(-1),
38+
createMockPersonalInfoQuestion(-2),
39+
],
40+
conditions: {},
41+
},
42+
],
43+
};
44+
45+
await expect(
46+
updateSurvey(surveyWithTwoPersonalInfoQuestions),
47+
).rejects.toThrow(BadRequestError);
48+
await expect(
49+
updateSurvey(surveyWithTwoPersonalInfoQuestions),
50+
).rejects.toThrow('Section count limits not respected.');
51+
});
552
});
653
});

server/src/application/survey.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,12 @@ import { compressImage } from '@src/utils';
4444
import { Geometry } from 'geojson';
4545
import pgPromise from 'pg-promise';
4646

47+
const surveySectionCountLimitations: Partial<
48+
Record<SurveyPageSection['type'], number>
49+
> = {
50+
'personal-info': 1,
51+
};
52+
4753
const sectionTypesWithOptions: SurveyPageSection['type'][] = [
4854
'radio',
4955
'checkbox',
@@ -1670,6 +1676,9 @@ async function updateSurveySections(survey: Survey, t: pgPromise.ITask<{}>) {
16701676
return [...result, ...surveySectionsToRows(page.sections, page.id)];
16711677
}, [] as SurveySectionRow[]);
16721678

1679+
if (!areSectionCountLimitsRespected(sections)) {
1680+
throw new BadRequestError('Section count limits not respected.');
1681+
}
16731682
// Delete sections that were removed from the updated survey
16741683
await deleteRemovedSections(survey.id, sections, t);
16751684

@@ -2816,3 +2825,19 @@ export async function getDistinctAutoSendToEmails() {
28162825
`);
28172826
return rows.map((row) => row.email);
28182827
}
2828+
2829+
function areSectionCountLimitsRespected(sections: SurveySectionRow[]): boolean {
2830+
const sectionCounts: Partial<Record<SurveyPageSection['type'], number>> = {};
2831+
2832+
for (const section of sections) {
2833+
const type = section.type;
2834+
sectionCounts[type] = (sectionCounts[type] ?? 0) + 1;
2835+
2836+
const limit = surveySectionCountLimitations[type];
2837+
if (limit !== undefined && sectionCounts[type] > limit) {
2838+
return false;
2839+
}
2840+
}
2841+
2842+
return true;
2843+
}

0 commit comments

Comments
 (0)