diff --git a/apps/backend/db_patches/0209_addBaseulrtodynamiccs.sql b/apps/backend/db_patches/0209_addBaseulrtodynamiccs.sql new file mode 100644 index 0000000000..92d71c1933 --- /dev/null +++ b/apps/backend/db_patches/0209_addBaseulrtodynamiccs.sql @@ -0,0 +1,16 @@ +DO +$$ +BEGIN + IF register_patch('addBaseulrtodynamiccs.sql', 'Zachary Hankin', 'Adds an option to the dynamic HTTP question type allowing use of base domain', '2026-5-21') THEN + UPDATE questions + SET default_config = jsonb_set( + default_config::jsonb, + '{useBaseDomain}', + 'false'::jsonb, + true + ) + WHERE data_type = 'DYNAMIC_MULTIPLE_CHOICE'; + END IF; +END; +$$ +LANGUAGE plpgsql; diff --git a/apps/backend/db_patches/db_seeds/0005_ProposalQuestions.sql b/apps/backend/db_patches/db_seeds/0005_ProposalQuestions.sql index ac01b60896..00d33f8448 100644 --- a/apps/backend/db_patches/db_seeds/0005_ProposalQuestions.sql +++ b/apps/backend/db_patches/db_seeds/0005_ProposalQuestions.sql @@ -376,7 +376,7 @@ VALUES ( 'dynamic_multiple_choice_question', 'DYNAMIC_MULTIPLE_CHOICE', 'Dynamic multiple choice question from seeds', - '{"variant":"dropdown", "url":"", "jsonPath":"","isMultipleSelect":true, "apiCallRequestHeaders":[], "readPermissions":[]}', + '{"variant":"dropdown", "url":"", "jsonPath":"","isMultipleSelect":true, "apiCallRequestHeaders":[], "readPermissions":[], "useBaseDomain":false}', '2023-02-08 10:23:10.285415+00', '2023-02-08 10:23:10.285415+00', 'dynamic_multiple_choice_question', @@ -389,7 +389,7 @@ INSERT INTO templates_has_questions( VALUES ( 'dynamic_multiple_choice_question', - proposal_template_id_var, proposal_topic_id_var, 10, '{"variant":"dropdown", "url":"", "jsonPath":"","isMultipleSelect":true, "apiCallRequestHeaders":[],"readPermissions":[]}' + proposal_template_id_var, proposal_topic_id_var, 10, '{"useBaseDomain":false, "variant":"dropdown", "url":"", "jsonPath":"","isMultipleSelect":true, "apiCallRequestHeaders":[],"readPermissions":[]}' ); INSERT INTO answers( questionary_id, question_id, answer diff --git a/apps/backend/example.development.env b/apps/backend/example.development.env index dde1b439b0..60b944e96f 100644 --- a/apps/backend/example.development.env +++ b/apps/backend/example.development.env @@ -44,7 +44,7 @@ NODE_ENV=development # DEPENDENCY_CONFIG= PING_PUBLIC_CRT=dummypingsecret DATABASE_URL=postgres://duouser:duopassword@127.0.0.1:5432/duo -BASE_URL=localhost:3000 +BASE_URL=http://localhost:3000 JWT_TOKEN_LIFE=7d JWT_SECRET=qMyLZALzs229ybdQXNyzYRdju7X784TH # SPARKPOST_TOKEN=insertokenhere diff --git a/apps/backend/package-lock.json b/apps/backend/package-lock.json index 87718be59f..a78ed249a1 100644 --- a/apps/backend/package-lock.json +++ b/apps/backend/package-lock.json @@ -7932,6 +7932,18 @@ "node": ">=10.0.0" } }, + "node_modules/@zone-eu/mailsplit": { + "version": "5.4.13", + "resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.13.tgz", + "integrity": "sha512-j40NeNlSAivqnKEjzZYsGP/bKWr2zwuyb8XOYsC3i0ZlfM5UnTYTa7aCRkE8rYQvVzE1dRjVYdvZ1Epi6x+h6w==", + "license": "(MIT OR EUPL-1.1+)", + "optional": true, + "dependencies": { + "libbase64": "1.3.0", + "libmime": "5.4.0", + "libqp": "2.1.1" + } + }, "node_modules/accepts": { "version": "1.3.8", "license": "MIT", @@ -9356,6 +9368,16 @@ "node": ">=0.10.0" } }, + "node_modules/deepmerge-ts": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", + "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/define-data-property": { "version": "1.1.4", "license": "MIT", @@ -9773,7 +9795,9 @@ } }, "node_modules/encoding-japanese": { - "version": "2.0.0", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.2.0.tgz", + "integrity": "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==", "license": "MIT", "optional": true, "engines": { @@ -13023,23 +13047,29 @@ } }, "node_modules/libbase64": { - "version": "1.2.1", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.3.0.tgz", + "integrity": "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==", "license": "MIT", "optional": true }, "node_modules/libmime": { - "version": "5.2.1", + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/libmime/-/libmime-5.4.0.tgz", + "integrity": "sha512-MAWyU2qYtCsrZ6GClgukz2jx73NQrzA6ASo9qzThuTG+A7+H3lWQuBsjoy9lls+v6q/epAeBdEMJ3T0f4b+uRw==", "license": "MIT", "optional": true, "dependencies": { - "encoding-japanese": "2.0.0", - "iconv-lite": "0.6.3", - "libbase64": "1.2.1", - "libqp": "2.0.1" + "encoding-japanese": "2.2.0", + "iconv-lite": "0.7.2", + "libbase64": "1.3.0", + "libqp": "2.1.1" } }, "node_modules/libmime/node_modules/iconv-lite": { - "version": "0.6.3", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", "license": "MIT", "optional": true, "dependencies": { @@ -13047,6 +13077,10 @@ }, "engines": { "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/libphonenumber-js": { @@ -13054,7 +13088,9 @@ "license": "MIT" }, "node_modules/libqp": { - "version": "2.0.1", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/libqp/-/libqp-2.1.1.tgz", + "integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==", "license": "MIT", "optional": true }, @@ -13079,7 +13115,19 @@ "license": "MIT" }, "node_modules/linkify-it": { - "version": "5.0.0", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.1.tgz", + "integrity": "sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], "license": "MIT", "optional": true, "dependencies": { @@ -13193,52 +13241,157 @@ "license": "MIT" }, "node_modules/mailparser": { - "version": "3.6.7", + "version": "3.9.12", + "resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.9.12.tgz", + "integrity": "sha512-kbT4xtkKEddonhDTjjWWVFJlI5axm0S9QuIAHs1ue3IEw+fNd9o4ytPKaG7p1LOE2aKifrWLjx/omOGLHz/9RQ==", "license": "MIT", "optional": true, "dependencies": { - "encoding-japanese": "2.0.0", + "@zone-eu/mailsplit": "5.4.13", + "encoding-japanese": "2.2.0", "he": "1.2.0", - "html-to-text": "9.0.5", - "iconv-lite": "0.6.3", - "libmime": "5.2.1", - "linkify-it": "5.0.0", - "mailsplit": "5.4.0", - "nodemailer": "6.9.9", - "tlds": "1.248.0" + "html-to-text": "10.0.0", + "iconv-lite": "0.7.2", + "libmime": "5.4.0", + "linkify-it": "5.0.1", + "nodemailer": "9.0.1", + "punycode.js": "2.3.1", + "tlds": "1.261.0" } }, - "node_modules/mailparser/node_modules/iconv-lite": { - "version": "0.6.3", + "node_modules/mailparser/node_modules/@selderee/plugin-htmlparser2": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.12.0.tgz", + "integrity": "sha512-oELmoyA6ML9jDRMV3kgcMQFKxUfBU0yFVn6yTctVaLT5ygXnxH52I3TZEgV9EhXJC68/uFvE5Daj1/25c0Xa/A==", "license": "MIT", "optional": true, "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" + "domelementtype": "~2.3.0", + "domhandler": "~5.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + }, + "peerDependencies": { + "selderee": "~0.12.0" + } + }, + "node_modules/mailparser/node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "optional": true, + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/mailparser/node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "optional": true, + "dependencies": { + "domelementtype": "^2.3.0" }, "engines": { - "node": ">=0.10.0" + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/mailparser/node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "optional": true, + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/mailparser/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "optional": true, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/mailparser/node_modules/tlds": { - "version": "1.248.0", + "node_modules/mailparser/node_modules/html-to-text": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-10.0.0.tgz", + "integrity": "sha512-2OH59Gtprdczel+7Rxgpz9hGVJREaf8Lt1H4kZwWHpEn70VQKRuMNGsb2eDbwaTzrYzb0hheiOG1P7Dim0B4dQ==", "license": "MIT", "optional": true, - "bin": { - "tlds": "bin.js" + "dependencies": { + "@selderee/plugin-htmlparser2": "~0.12.0", + "deepmerge-ts": "^7.1.5", + "dom-serializer": "^2.0.0", + "htmlparser2": "^10.1.0", + "selderee": "~0.12.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/KillyMXI" } }, - "node_modules/mailsplit": { - "version": "5.4.0", - "license": "(MIT OR EUPL-1.1+)", + "node_modules/mailparser/node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", "optional": true, "dependencies": { - "libbase64": "1.2.1", - "libmime": "5.2.0", - "libqp": "2.0.1" + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" } }, - "node_modules/mailsplit/node_modules/iconv-lite": { - "version": "0.6.3", + "node_modules/mailparser/node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "optional": true, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/mailparser/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", "license": "MIT", "optional": true, "dependencies": { @@ -13246,17 +13399,67 @@ }, "engines": { "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/mailsplit/node_modules/libmime": { - "version": "5.2.0", + "node_modules/mailparser/node_modules/leac": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/leac/-/leac-0.7.0.tgz", + "integrity": "sha512-qMrZeyEekgdRQ9o6a4NAB2EQZrv827GJdn1vnapwSJ90hWRB4TzUSunvacPkxQ2TnNqHNI1/zSt0hlo0crG8Jw==", + "license": "MIT", + "optional": true, + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + } + }, + "node_modules/mailparser/node_modules/nodemailer": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.1.tgz", + "integrity": "sha512-Gwv8SQewT616ZM/URn0H54b8PWo/Wum7md3EW2aWy1lO27+WZCX+Xyak3J+NlmHUjDh5ME+uesJUDRbR3Ye8Bw==", + "license": "MIT-0", + "optional": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/mailparser/node_modules/parseley": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/parseley/-/parseley-0.13.1.tgz", + "integrity": "sha512-uNBJZzmb60l6p6VWLTmevizNAGnE0xoSf1n0B4q3ntegDNzcS68NRCcBDZTcyXHxt2XhBChsCuqj4M+nChvE/A==", + "license": "MIT", + "optional": true, + "dependencies": { + "leac": "^0.7.0", + "peberminta": "^0.10.0" + }, + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + } + }, + "node_modules/mailparser/node_modules/peberminta": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.10.0.tgz", + "integrity": "sha512-80B2AsU+I4Qdb0ZAPSfe9UwvGzwkM37IKIFEvdS3D/3Ndgv2bsuJ0bfG1+iEYO+l7Gfd4EUJmuRyq7efLgRMzQ==", + "license": "MIT", + "optional": true, + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + } + }, + "node_modules/mailparser/node_modules/selderee": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.12.0.tgz", + "integrity": "sha512-b1YMh3+DHZp59DLna3qVwQ5iOla/nrI6mLBNW02XxU77M3046Df6VLkoaJyFz20VsGIG5kkp+FK0kg4K4HnUFw==", "license": "MIT", "optional": true, "dependencies": { - "encoding-japanese": "2.0.0", - "iconv-lite": "0.6.3", - "libbase64": "1.2.1", - "libqp": "2.0.1" + "parseley": "~0.13.1" + }, + "funding": { + "url": "https://github.com/sponsors/KillyMXI" } }, "node_modules/make-dir": { @@ -14835,6 +15038,16 @@ "node": ">=6" } }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, "node_modules/pure-rand": { "version": "6.0.4", "dev": true, @@ -16630,7 +16843,9 @@ } }, "node_modules/uc.micro": { - "version": "2.0.0", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", "license": "MIT", "optional": true }, diff --git a/apps/backend/src/config/dependencyConfigDLS.ts b/apps/backend/src/config/dependencyConfigDLS.ts index d942f98755..0e506f2ec2 100644 --- a/apps/backend/src/config/dependencyConfigDLS.ts +++ b/apps/backend/src/config/dependencyConfigDLS.ts @@ -1,15 +1,15 @@ import { ConsoleLogger, setLogger } from '@user-office-software/duo-logger'; import 'reflect-metadata'; +import { getSecondsFromAllocationTimeUnits } from './base/allocationTimeUnitConverter'; +import { configureDLSEnvironment } from './dls/configureDLSEnvironment'; import { Tokens } from './Tokens'; +import { mapClass, mapValue } from './utils'; import { DataAccessUsersAuthorization } from '../auth/DataAccessUsersAuthorization'; import { DLSUserAuthorization } from '../auth/DLSUserAuthorization'; -import { getSecondsFromAllocationTimeUnits } from './base/allocationTimeUnitConverter'; import { ProposalAuthorization } from '../auth/ProposalAuthorization'; import { VisitAuthorization } from '../auth/VisitAuthorization'; import { VisitRegistrationAuthorization } from '../auth/VisitRegistrationAuthorization'; -import { configureDLSEnvironment } from './dls/configureDLSEnvironment'; -import { mapClass, mapValue } from './utils'; import { PostgresAdminDataSourceWithAutoUpgrade } from '../datasources/postgres/AdminDataSource'; import PostgresCallDataSource from '../datasources/postgres/CallDataSource'; import PostgresCoProposerClaimDataSource from '../datasources/postgres/CoProposerClaimDataSource'; diff --git a/apps/backend/src/config/dls/configureDLSEnvironment.ts b/apps/backend/src/config/dls/configureDLSEnvironment.ts index cff8fe28e9..640993ac0a 100644 --- a/apps/backend/src/config/dls/configureDLSEnvironment.ts +++ b/apps/backend/src/config/dls/configureDLSEnvironment.ts @@ -3,6 +3,7 @@ import { container } from 'tsyringe'; import { AdminDataSource } from '../../datasources/AdminDataSource'; import { FeatureId } from '../../models/Feature'; import { SettingsId } from '../../models/Settings'; +import { isDevelopment, isStaging } from '../../utils/helperFunctions'; import { setTimezone, setDateTimeFormats } from '../setTimezoneAndFormat'; import { Tokens } from '../Tokens'; import { updateOIDCSettings } from '../updateOIDCSettings'; @@ -16,7 +17,7 @@ function getBaseURL() { return url; } -async function setDLSColourTheme() { +async function setDLSColourTheme(primaryMainColour: string) { const db = container.resolve(Tokens.AdminDataSource); await db.waitForDBUpgrade(); @@ -28,7 +29,7 @@ async function setDLSColourTheme() { }), db.updateSettings({ settingsId: SettingsId.PALETTE_PRIMARY_MAIN, - settingsValue: '#202945', + settingsValue: primaryMainColour, }), db.updateSettings({ settingsId: SettingsId.PALETTE_PRIMARY_LIGHT, @@ -142,13 +143,20 @@ async function enableDefaultDLSFeatures() { } async function configureDLSEnvironment() { - await Promise.all([ - setDLSColourTheme(), - enableDefaultDLSFeatures(), - setTimezone(), - setDateTimeFormats(), - updateOIDCSettings(), - ]); + if (isDevelopment) { + await setDLSColourTheme('#b3022b'); + } + if (isStaging) { + await setDLSColourTheme('#8B008B'); + } + if (isDevelopment) { + await Promise.all([ + enableDefaultDLSFeatures(), + setTimezone(), + setDateTimeFormats(), + updateOIDCSettings(), + ]); + } } export { configureDLSEnvironment, getBaseURL }; diff --git a/apps/backend/src/datasources/mockups/TemplateDataSource.ts b/apps/backend/src/datasources/mockups/TemplateDataSource.ts index c65f132d48..025ff05186 100644 --- a/apps/backend/src/datasources/mockups/TemplateDataSource.ts +++ b/apps/backend/src/datasources/mockups/TemplateDataSource.ts @@ -126,6 +126,20 @@ const dummyTemplateStepsFactory = () => { config: { url: '', jsonPath: '', + useBaseDomain: false, + } as DynamicMultipleChoiceConfig, + }), + }); + + const dmcQuestionWithBaseDomain = dummyQuestionTemplateRelationFactory({ + question: dummyQuestionFactory({ + id: 'dmcQuestionWithBaseDomain', + naturalKey: 'dmcQuestionWithBaseDomain', + dataType: DataType.DYNAMIC_MULTIPLE_CHOICE, + config: { + url: 'getListOfCountries', + useBaseDomain: true, + jsonPath: '', } as DynamicMultipleChoiceConfig, }), }); @@ -138,6 +152,7 @@ const dummyTemplateStepsFactory = () => { config: { url: 'api-url', jsonPath: '', + useBaseDomain: false, apiCallRequestHeaders: [ { name: 'header1', @@ -160,6 +175,7 @@ const dummyTemplateStepsFactory = () => { config: { url: 'api-url', jsonPath: '$..option', + useBaseDomain: false, } as DynamicMultipleChoiceConfig, }), }); @@ -175,6 +191,7 @@ const dummyTemplateStepsFactory = () => { dmcQuestionEmptyUrl, dmcQuestionEmptyJsonPath, dmcQuestionWithUrlAndJsonPath, + dmcQuestionWithBaseDomain, ]), ]; }; diff --git a/apps/backend/src/eventHandlers/email/DLS/proposalCoProposerInvitesUpdatedHandler.ts b/apps/backend/src/eventHandlers/email/DLS/proposalCoProposerInvitesUpdatedHandler.ts index fb13ea2edf..d3178aa09f 100644 --- a/apps/backend/src/eventHandlers/email/DLS/proposalCoProposerInvitesUpdatedHandler.ts +++ b/apps/backend/src/eventHandlers/email/DLS/proposalCoProposerInvitesUpdatedHandler.ts @@ -59,7 +59,8 @@ export async function proposalCoProposerInvitesUpdatedHandler( template: emailTemplate.id.toString(), }, substitution_data: { - sender: inviter.preferredname + ' ' + inviter.lastname, + sender: + (inviter.preferredname || inviter.firstname) + ' ' + inviter.lastname, redeem_code: invite.code, uos_instance: process.env.BASE_URL, }, diff --git a/apps/backend/src/eventHandlers/email/DLS/proposalSubmittedHandler.ts b/apps/backend/src/eventHandlers/email/DLS/proposalSubmittedHandler.ts index 414acb4222..b3ed457832 100644 --- a/apps/backend/src/eventHandlers/email/DLS/proposalSubmittedHandler.ts +++ b/apps/backend/src/eventHandlers/email/DLS/proposalSubmittedHandler.ts @@ -10,9 +10,39 @@ import { QuestionaryDataSource } from '../../../datasources/QuestionaryDataSourc import { UserDataSource } from '../../../datasources/UserDataSource'; import { ApplicationEvent } from '../../../events/applicationEvents'; import { Event } from '../../../events/event.enum'; +import { getTopicActiveAnswers } from '../../../models/ProposalModelFunctions'; +import { Answer, QuestionaryStep } from '../../../models/Questionary'; +import { DataType } from '../../../models/Template'; import { MailService } from '../../MailService/MailService'; import { EmailTemplateId } from '../emailTemplateId'; +type InstrumentPickerAnswerValue = { + instrumentId: number | string; + timeRequested?: number | string | null; +}; + +const getInstrumentPickerAnswers = ( + questionarySteps: QuestionaryStep[] +): Answer[] => + questionarySteps + .flatMap((step) => getTopicActiveAnswers(questionarySteps, step.topic.id)) + .filter( + (answer) => answer.question.dataType === DataType.INSTRUMENT_PICKER + ); + +const getInstrumentPickerAnswerValues = ( + value: unknown +): InstrumentPickerAnswerValue[] => { + const values = Array.isArray(value) ? value : value ? [value] : []; + + return values.filter( + (instrumentAnswer): instrumentAnswer is InstrumentPickerAnswerValue => + typeof instrumentAnswer === 'object' && + instrumentAnswer !== null && + 'instrumentId' in instrumentAnswer + ); +}; + export async function proposalSubmittedHandler(event: ApplicationEvent) { if (event.type != Event.PROPOSAL_SUBMITTED) return; @@ -54,28 +84,49 @@ export async function proposalSubmittedHandler(event: ApplicationEvent) { event.proposal.callId ); - const instruments = await instrumentSource.getInstrumentsByProposalPk( - event.proposal.primaryKey + const questionarySteps = await questionaryDataSource.getQuestionarySteps( + event.proposal.questionaryId ); - // Postgres implementation doesn't match interface - impliementation wants questionaryId, not proposalId - const answer = await questionaryDataSource.getAnswer( - event.proposal.questionaryId, - 'instrument_picker' + const instrumentPickerAnswers = getInstrumentPickerAnswers(questionarySteps); + const instrumentPickerAnswerValues = instrumentPickerAnswers.flatMap( + (answer) => getInstrumentPickerAnswerValues(answer.value) + ); + const instrumentIds = Array.from( + new Set( + instrumentPickerAnswerValues + .map((instrumentAnswer) => Number(instrumentAnswer.instrumentId)) + .filter((instrumentId) => Number.isFinite(instrumentId)) + ) ); - ( - answer?.answer as { - value: { instrumentId: number; timeRequested: number }[]; - } - )?.value.forEach((instrumentAnswer: any) => { - const instrument = instruments.find( - (inst) => inst.id === Number(instrumentAnswer.instrumentId) - ); - if (instrument) { - instrument.managementTimeAllocation = instrumentAnswer.timeRequested || 0; - } - }); + const instruments = await instrumentSource.getInstrumentsByIds(instrumentIds); + const requested = instrumentPickerAnswerValues + .map((instrumentAnswer) => { + const requestedTime = Number(instrumentAnswer.timeRequested ?? 0); + const formattedRequestedTime = Number.isFinite(requestedTime) + ? requestedTime + : 0; + const instrument = instruments.find( + (inst) => inst.id === Number(instrumentAnswer.instrumentId) + ); + if (!instrument) { + return null; + } + + const timeUnit = `${call.allocationTimeUnit}${ + formattedRequestedTime > 1 || formattedRequestedTime === 0 ? 's' : '' + }`; + + return [ + `${instrument.name}:`, + instrument.description, + formattedRequestedTime, + timeUnit, + ].join(' '); + }) + .filter((request): request is string => request !== null) + .join(', '); const shortDateFormat = new Intl.DateTimeFormat('en-GB', { month: 'short', @@ -115,19 +166,17 @@ export async function proposalSubmittedHandler(event: ApplicationEvent) { submittedOn: event.proposal.submittedDate!.toLocaleString(), accessRoute: workflow?.name || 'N/A', principalInvestigator: - principalInvestigator.preferredname + + (principalInvestigator.preferredname || + principalInvestigator.firstname) + ' ' + principalInvestigator.lastname, establishment: principalInvestigator.institution, alternativeContacts: '', coinvestigators: participants.map( - (partipant) => `${partipant.preferredname} ${partipant.lastname} ` + (partipant) => + `${partipant.preferredname || partipant.firstname} ${partipant.lastname} ` ), - requested: instruments - .map((instrument) => { - return `${instrument.name}: ${instrument.description} ${instrument.managementTimeAllocation} ${call.allocationTimeUnit}${instrument.managementTimeAllocation > 1 ? 's' : ''}`; - }) - .join(', '), + requested, }, allocationPeriod: allocationPeriod, deadline: longDateFormat.format(call.endCall), @@ -153,7 +202,7 @@ export async function proposalSubmittedHandler(event: ApplicationEvent) { ...options, substitution_data: { ...(options.substitution_data as any), - name: participant.preferredname, + name: participant.preferredname || participant.firstname, }, recipients: [ { diff --git a/apps/backend/src/models/questionTypes/DynamicMultipleChoice.ts b/apps/backend/src/models/questionTypes/DynamicMultipleChoice.ts index 4d4ed3d602..f51d72f950 100644 --- a/apps/backend/src/models/questionTypes/DynamicMultipleChoice.ts +++ b/apps/backend/src/models/questionTypes/DynamicMultipleChoice.ts @@ -29,6 +29,7 @@ export const dynamicMultipleChoiceDefinition: Question { expect(options).toEqual(['option1', 'option2']); }); + it('Should return options if selected useBaseDomain', async () => { + process.env = { + ...process.env, + BASE_URL: 'http://mocked.example.com', + }; + + jest + .spyOn(global, 'fetch') + .mockImplementation((input: RequestInfo | URL) => { + const url = typeof input === 'string' ? input : input.toString(); + + if (url === 'http://mocked.example.com/getListOfCountries') { + return Promise.resolve({ + json: () => Promise.resolve(['option1', 'option2']), + ok: true, + } as Response); + } else { + return Promise.reject(new Error('Unknown URL')); + } + }); + + const options = await templateQueries.getDynamicMultipleChoiceOptions( + dummyUserWithRole, + 'dmcQuestionWithBaseDomain' + ); + + expect(options).toEqual(['option1', 'option2']); + }); it('should use the question template relation if a template ID is supplied', async () => { const templateDataSource = container.resolve( Tokens.TemplateDataSource diff --git a/apps/backend/src/queries/TemplateQueries.ts b/apps/backend/src/queries/TemplateQueries.ts index b054ee4da5..d93abdfd7f 100644 --- a/apps/backend/src/queries/TemplateQueries.ts +++ b/apps/backend/src/queries/TemplateQueries.ts @@ -104,10 +104,11 @@ export default class TemplateQueries { if (!question) return []; const config = question.config as DynamicMultipleChoiceConfig; - if (config.url === '') return []; + const dynamicURL = constructDynamicURL(config.url, config.useBaseDomain); + if (dynamicURL === '') return []; try { - const response = await fetch(config.url, { + const response = await fetch(dynamicURL, { headers: config.apiCallRequestHeaders?.reduce( (acc, header) => ({ ...acc, @@ -166,3 +167,11 @@ export default class TemplateQueries { return this.dataSource.getQuestion(questionId); } } + +function constructDynamicURL(url: string, useBaseURL: boolean): string { + if (useBaseURL) { + return `${process.env.BASE_URL}/${url}`; + } + + return url; +} diff --git a/apps/backend/src/resolvers/queries/ServerConfigQuery.ts b/apps/backend/src/resolvers/queries/ServerConfigQuery.ts new file mode 100644 index 0000000000..8b83ba2fc0 --- /dev/null +++ b/apps/backend/src/resolvers/queries/ServerConfigQuery.ts @@ -0,0 +1,11 @@ +import { Query, Resolver } from 'type-graphql'; + +import { ServerConfig } from '../types/ServerConfig'; + +@Resolver() +export class ServerConfigQuery { + @Query(() => ServerConfig, { nullable: false }) + ServerConfig() { + return { baseURL: process.env.BASE_URL }; + } +} diff --git a/apps/backend/src/resolvers/types/FieldConfig.ts b/apps/backend/src/resolvers/types/FieldConfig.ts index 044b609d38..dd845a0bbe 100644 --- a/apps/backend/src/resolvers/types/FieldConfig.ts +++ b/apps/backend/src/resolvers/types/FieldConfig.ts @@ -156,6 +156,9 @@ export class DynamicMultipleChoiceConfig extends ConfigBase { @Field(() => String) jsonPath: string; + @Field(() => Boolean) + useBaseDomain: boolean; + @Field(() => Boolean) isMultipleSelect: boolean; diff --git a/apps/backend/src/resolvers/types/ServerConfig.ts b/apps/backend/src/resolvers/types/ServerConfig.ts new file mode 100644 index 0000000000..2ea09058e9 --- /dev/null +++ b/apps/backend/src/resolvers/types/ServerConfig.ts @@ -0,0 +1,7 @@ +import { ObjectType, Field } from 'type-graphql'; + +@ObjectType() +export class ServerConfig { + @Field(() => String) + public baseURL: string; +} diff --git a/apps/e2e/cypress/e2e/proposalPdfTemplates.cy.ts b/apps/e2e/cypress/e2e/proposalPdfTemplates.cy.ts index aa2367c4da..4a9884bfd6 100644 --- a/apps/e2e/cypress/e2e/proposalPdfTemplates.cy.ts +++ b/apps/e2e/cypress/e2e/proposalPdfTemplates.cy.ts @@ -98,6 +98,538 @@ context('Proposal PDF template tests', () => { createdTemplateName = result.createTemplate.name; } }); + + cy.updateProposalPdfTemplate({ + proposalPdfTemplateId: 1, + dummyData: `{ + "data": { + "proposal": { + "primaryKey": 4, + "title": "New Proposal", + "abstract": "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum", + "proposerId": 1, + "statusId": 1, + "created": "2023-10-18T11:38:15.429Z", + "updated": "2023-10-18T11:39:02.691Z", + "proposalId": "268490", + "callId": 1, + "questionaryId": 4, + "notified": false, + "submitted": true, + "referenceNumberSequence": 3, + "managementTimeAllocation": null, + "managementDecisionSubmitted": false + }, + "principalInvestigator": { + "id": 1, + "firstname": "Carl", + "lastname": "Carlsson", + "preferredname": "Carl", + "institution": "Other", + "institutionId": 1, + "position": "Strategist", + "created": "2023-10-10T13:32:18.172Z", + "placeholder": false, + "email": "Javon4@hotmail.com" + }, + "coProposers": [ + { + "id": 4, + "firstname": "Benjamin", + "lastname": "Beckley", + "preferredname": "Benjamin", + "institution": "Other", + "institutionId": 1, + "position": "Management", + "created": "2023-10-18T11:38:15.429Z", + "placeholder": false, + "email": "ben@inbox.com" + } + ], + "questionarySteps": [ + { + "questionaryId": 4, + "topic": { + "id": 41, + "title": "Samples & Info", + "templateId": 39, + "sortOrder": 1, + "isEnabled": true + }, + "isCompleted": true, + "fields": [ + { + "question": { + "categoryId": 1, + "id": "date_1661426719613", + "naturalKey": "date_1661426719613", + "dataType": "DATE", + "question": "When do you want to come?", + "config": { + "small_label": "", + "required": false, + "tooltip": "", + "includeTime": false, + "maxDate": null, + "minDate": null, + "defaultDate": null + } + }, + "topicId": 41, + "sortOrder": 0, + "config": { + "small_label": "", + "required": false, + "tooltip": "", + "includeTime": false, + "maxDate": null, + "minDate": null, + "defaultDate": null + }, + "dependencies": [], + "dependenciesOperator": "AND", + "answerId": 4, + "value": null + }, + { + "question": { + "categoryId": 1, + "id": "sample_declaration_1666269436134", + "naturalKey": "sample_declaration_1666269436134", + "dataType": "SAMPLE_DECLARATION", + "question": "Samples", + "config": { + "addEntryButtonLabel": "Add", + "templateCategory": "SAMPLE_DECLARATION", + "templateId": 36, + "esiTemplateId": null, + "small_label": "", + "required": false, + "maxEntries": null, + "minEntries": null + } + }, + "topicId": 41, + "sortOrder": 1, + "config": { + "addEntryButtonLabel": "Add", + "templateCategory": "SAMPLE_DECLARATION", + "templateId": 36, + "esiTemplateId": null, + "small_label": "", + "required": false, + "maxEntries": null, + "minEntries": null + }, + "dependencies": [], + "dependenciesOperator": "AND", + "answerId": null, + "value": [] + }, + { + "question": { + "categoryId": 1, + "id": "selection_from_options_1605784613207", + "naturalKey": "selection_from_options_instrument", + "dataType": "SELECTION_FROM_OPTIONS", + "question": "Select an instrument", + "config": { + "small_label": "", + "required": false, + "tooltip": "", + "variant": "dropdown", + "options": ["LoKi", "MX"], + "isMultipleSelect": false + } + }, + "topicId": 41, + "sortOrder": 2, + "config": { + "small_label": "", + "required": false, + "tooltip": "", + "variant": "dropdown", + "options": ["LoKi", "MX", "CRYO-EM"], + "isMultipleSelect": true + }, + "dependencies": [], + "dependenciesOperator": "AND", + "answerId": 5, + "value": ["MX"] + }, + { + "question": { + "categoryId": 1, + "id": "generic_template_1674814357714", + "naturalKey": "generic_template_1674814357714", + "dataType": "GENERIC_TEMPLATE", + "question": "MX", + "config": { + "addEntryButtonLabel": "Add", + "copyButtonLabel": "", + "canCopy": false, + "isMultipleCopySelect": false, + "isCompleteOnCopy": false, + "templateCategory": "GENERIC_TEMPLATE", + "templateId": 38, + "small_label": "", + "required": false, + "maxEntries": "1", + "minEntries": "1" + } + }, + "topicId": 41, + "sortOrder": 5, + "config": { + "addEntryButtonLabel": "Add", + "copyButtonLabel": "", + "canCopy": false, + "isMultipleCopySelect": false, + "isCompleteOnCopy": false, + "templateCategory": "GENERIC_TEMPLATE", + "templateId": 38, + "small_label": "", + "required": false, + "maxEntries": 1, + "minEntries": 1 + }, + "dependencies": [ + { + "questionId": "generic_template_1674814357714", + "dependencyId": "selection_from_options_1605784613207", + "dependencyNaturalKey": "selection_from_options_instrument", + "condition": { + "params": "MX", + "condition": "eq" + } + } + ], + "dependenciesOperator": "AND", + "answerId": null, + "value": [ + { + "id": 1, + "title": "template 1", + "creatorId": 1, + "proposalPk": 4, + "questionaryId": 5, + "questionId": "generic_template_1674814357714", + "created": "2023-10-18T11:38:39.861Z" + } + ] + }, + { + "question": { + "categoryId": 1, + "id": "boolean_1674812264143", + "naturalKey": "boolean_1674812264143", + "dataType": "BOOLEAN", + "question": "Do you want sample environment for MX", + "config": { + "small_label": "", + "required": false, + "tooltip": "" + } + }, + "topicId": 41, + "sortOrder": 6, + "config": { + "small_label": "", + "required": false, + "tooltip": "" + }, + "dependencies": [ + { + "questionId": "boolean_1674812264143", + "dependencyId": "selection_from_options_1605784613207", + "dependencyNaturalKey": "selection_from_options_instrument", + "condition": { + "params": "MX", + "condition": "eq" + } + } + ], + "dependenciesOperator": "AND", + "answerId": 6, + "value": true + }, + { + "question": { + "categoryId": 1, + "id": "embellishment_1636710963252", + "naturalKey": "embellishment_1636710963252", + "dataType": "EMBELLISHMENT", + "question": "New question", + "config": { + "html": "

Users can also submit Expression of Interest in the following Crystallisation services:

\\n
    \\n
  • Support for large protein crystal growth of either protiated or deuterated proteins (incl. sitting drop vapour diffusion, dialysis, batch, optimization, targeted screening, testing with X-rays)
  • \\n
", + "plain": "Crystallisation text", + "omitFromPdf": true + } + }, + "topicId": 41, + "sortOrder": 7, + "config": { + "html": "

Users can also submit Expression of Interest in the following Crystallisation services:

\\n
    \\n
  • Support for large protein crystal growth of either protiated or deuterated proteins (incl. sitting drop vapour diffusion, dialysis, batch, optimization, targeted screening, testing with X-rays)
  • \\n
", + "plain": "Crystallisation text", + "omitFromPdf": true + }, + "dependencies": [ + { + "questionId": "embellishment_1636710963252", + "dependencyId": "selection_from_options_1605784613207", + "dependencyNaturalKey": "selection_from_options_instrument", + "condition": { + "params": "MX", + "condition": "eq" + } + } + ], + "dependenciesOperator": "AND", + "answerId": null, + "value": null + } + ] + }, + { + "questionaryId": 4, + "topic": { + "id": 42, + "title": "Sample environment", + "templateId": 39, + "sortOrder": 2, + "isEnabled": true + }, + "isCompleted": true, + "fields": [ + { + "question": { + "categoryId": 1, + "id": "boolean_1674821942563", + "naturalKey": "boolean_1674821942563", + "dataType": "BOOLEAN", + "question": "Do you need lab-support?", + "config": { + "small_label": "", + "required": false, + "tooltip": "" + } + }, + "topicId": 42, + "sortOrder": 0, + "config": { + "small_label": "", + "required": false, + "tooltip": "" + }, + "dependencies": [], + "dependenciesOperator": "AND", + "answerId": 7, + "value": true + }, + { + "question": { + "categoryId": 1, + "id": "final_delivery_date_motivation", + "naturalKey": "final_delivery_date_motivation", + "dataType": "TEXT_INPUT", + "question": "Please give a brief explanation for the chosen date", + "config": { + "required": true, + "small_label": "", + "tooltip": "", + "htmlQuestion": "", + "isHtmlQuestion": false, + "min": "1", + "max": 500, + "multiline": true, + "placeholder": "(maximum 500 characters)", + "isCounterHidden": false + } + }, + "topicId": 42, + "sortOrder": 1, + "config": { + "required": true, + "small_label": "", + "tooltip": "", + "htmlQuestion": "", + "isHtmlQuestion": false, + "min": 1, + "max": 500, + "multiline": true, + "placeholder": "(maximum 500 characters)", + "isCounterHidden": false + }, + "dependencies": [ + { + "questionId": "final_delivery_date_motivation", + "dependencyId": "boolean_1674821942563", + "dependencyNaturalKey": "boolean_1674821942563", + "condition": { + "params": true, + "condition": "eq" + } + } + ], + "dependenciesOperator": "AND", + "answerId": 8, + "value": "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, an" + } + ] + } + ], + "attachments": [], + "samples": [], + "genericTemplates": [ + { + "genericTemplate": { + "id": 1, + "title": "template 1", + "creatorId": 1, + "proposalPk": 4, + "questionaryId": 5, + "questionId": "generic_template_1674814357714", + "created": "2023-10-18T11:38:39.861Z" + }, + "genericTemplateQuestionaryFields": [ + { + "question": { + "categoryId": 7, + "id": "generic_template_basis", + "naturalKey": "generic_template_basis", + "dataType": "GENERIC_TEMPLATE_BASIS", + "question": "Template basic information", + "config": { + "titlePlaceholder": "Title", + "questionLabel": "", + "tooltip": "", + "required": false, + "small_label": "" + } + }, + "topicId": 39, + "sortOrder": 0, + "config": { + "titlePlaceholder": "Title", + "questionLabel": "", + "tooltip": "", + "required": false, + "small_label": "" + }, + "dependencies": [], + "dependenciesOperator": "AND", + "answerId": null, + "value": null + }, + { + "question": { + "categoryId": 7, + "id": "boolean_1674814267658", + "naturalKey": "boolean_1674814267658", + "dataType": "BOOLEAN", + "question": "New question", + "config": { + "small_label": "", + "required": false, + "tooltip": "" + } + }, + "topicId": 39, + "sortOrder": 1, + "config": { + "small_label": "", + "required": false, + "tooltip": "" + }, + "dependencies": [], + "dependenciesOperator": "AND", + "answerId": 1, + "value": true + }, + { + "question": { + "categoryId": 7, + "id": "date_1674814304449", + "naturalKey": "date_1674814304449", + "dataType": "DATE", + "question": "New question", + "config": { + "small_label": "", + "required": false, + "tooltip": "", + "includeTime": false, + "maxDate": null, + "minDate": null, + "defaultDate": null + } + }, + "topicId": 39, + "sortOrder": 2, + "config": { + "small_label": "", + "required": false, + "tooltip": "", + "includeTime": false, + "maxDate": null, + "minDate": null, + "defaultDate": null + }, + "dependencies": [], + "dependenciesOperator": "AND", + "answerId": 2, + "value": "2023-10-19T22:00:00.000Z" + }, + { + "question": { + "categoryId": 7, + "id": "text_input_1674814274952", + "naturalKey": "text_input_1674814274952", + "dataType": "TEXT_INPUT", + "question": "New question", + "config": { + "required": false, + "small_label": "", + "tooltip": "", + "htmlQuestion": "", + "isHtmlQuestion": false, + "min": null, + "max": null, + "multiline": false, + "placeholder": "", + "isCounterHidden": false + } + }, + "topicId": 39, + "sortOrder": 3, + "config": { + "required": false, + "small_label": "", + "tooltip": "", + "htmlQuestion": "", + "isHtmlQuestion": false, + "min": null, + "max": null, + "multiline": false, + "placeholder": "", + "isCounterHidden": false + }, + "dependencies": [], + "dependenciesOperator": "AND", + "answerId": 3, + "value": "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum" + } + ] + } + ] + }, + "userRole": { + "id": 2, + "shortCode": "user_officer", + "title": "User Officer" + } +} +`, + }); }); it('User officer can modify PDF template', () => { @@ -218,6 +750,26 @@ context('Proposal PDF template tests', () => { cy.contains(newDescription).should('exist'); }); + it.only('User officer opens a PDF template editor → PDF preview renders without error', () => { + cy.login('officer'); + cy.visit('/'); + + cy.navigateToTemplatesSubmenu('PDF (Proposal)'); + + cy.contains(createdTemplateName) + .parent() + .find("[aria-label='Edit']") + .click(); + + cy.contains(createdTemplateName).should('exist'); + + cy.get('[data-cy="pdf-template-preview"] canvas', { + timeout: 60000, + }).should('be.visible'); + + cy.get('[data-cy="pdf-template-preview-error"]').should('not.exist'); + }); + it('User officer can clone PDF template', () => { cy.login('officer'); cy.visit('/'); diff --git a/apps/e2e/cypress/e2e/templateContext.ts b/apps/e2e/cypress/e2e/templateContext.ts index 95f08ec547..2095f43550 100644 --- a/apps/e2e/cypress/e2e/templateContext.ts +++ b/apps/e2e/cypress/e2e/templateContext.ts @@ -66,6 +66,7 @@ const dynamicMultipleChoiceQuestion = { title: faker.lorem.words(2), url: 'http://localhost:9000', jsonPath: '$.*.item', + useBaseDomain: false, answers: { arrayString: [ faker.lorem.words(3), diff --git a/apps/e2e/cypress/e2e/templatesBasic.cy.ts b/apps/e2e/cypress/e2e/templatesBasic.cy.ts index f8e65633ba..ae9c77d252 100644 --- a/apps/e2e/cypress/e2e/templatesBasic.cy.ts +++ b/apps/e2e/cypress/e2e/templatesBasic.cy.ts @@ -323,6 +323,7 @@ context('Template Basic tests', () => { { url: dynamicMultipleChoiceQuestion.url, isMultipleSelect: true, + useBaseDomain: false, } ); @@ -1547,6 +1548,7 @@ context('Template Basic tests', () => { jsonPath: '$.[*].item', isMultipleSelect: true, firstTopic: true, + useBaseDomain: false, } ); @@ -1567,6 +1569,7 @@ context('Template Basic tests', () => { jsonPath: dynamicMultipleChoiceQuestion.jsonPath, isMultipleSelect: true, firstTopic: true, + useBaseDomain: false, } ); createProposalAndClickDropdownBehavior(); @@ -1589,6 +1592,7 @@ context('Template Basic tests', () => { url: dynamicMultipleChoiceQuestion.url, isMultipleSelect: true, firstTopic: true, + useBaseDomain: false, } ); createProposalAndClickDropdownBehavior(); @@ -1614,6 +1618,7 @@ context('Template Basic tests', () => { isMultipleSelect: true, firstTopic: true, headers: { Authorization: 'Bearer 1234', 'Content-Type': 'text/' }, + useBaseDomain: false, } ); diff --git a/apps/e2e/cypress/fixtures/template_export.json b/apps/e2e/cypress/fixtures/template_export.json index 3d1957f07f..9363048cc5 100644 --- a/apps/e2e/cypress/fixtures/template_export.json +++ b/apps/e2e/cypress/fixtures/template_export.json @@ -458,6 +458,7 @@ "variant": "dropdown", "url": "", "jsonPath": "", + "useBaseDomain": false, "isMultipleSelect": true, "apiCallRequestHeaders": [], "readPermissions": [] @@ -473,6 +474,7 @@ "variant": "dropdown", "url": "", "jsonPath": "", + "useBaseDomain": false, "isMultipleSelect": true, "apiCallRequestHeaders": [], "readPermissions": [] @@ -785,6 +787,7 @@ "variant": "dropdown", "url": "", "jsonPath": "", + "useBaseDomain": false, "isMultipleSelect": true, "apiCallRequestHeaders": [], "readPermissions": [] @@ -1171,6 +1174,7 @@ "variant": "dropdown", "url": "", "jsonPath": "", + "useBaseDomain": false, "isMultipleSelect": true, "apiCallRequestHeaders": [], "readPermissions": [] @@ -1186,6 +1190,7 @@ "variant": "dropdown", "url": "", "jsonPath": "", + "useBaseDomain": false, "isMultipleSelect": true, "apiCallRequestHeaders": [], "readPermissions": [] @@ -1403,6 +1408,7 @@ "variant": "dropdown", "url": "", "jsonPath": "", + "useBaseDomain": false, "isMultipleSelect": true, "apiCallRequestHeaders": [], "readPermissions": [] diff --git a/apps/e2e/cypress/support/template.ts b/apps/e2e/cypress/support/template.ts index b36fe60fc0..324e909bc0 100644 --- a/apps/e2e/cypress/support/template.ts +++ b/apps/e2e/cypress/support/template.ts @@ -20,6 +20,8 @@ import { UpdateQuestionMutationVariables, UpdateQuestionTemplateRelationSettingsMutation, UpdateQuestionTemplateRelationSettingsMutationVariables, + UpdateProposalPdfTemplateMutation, + UpdateProposalPdfTemplateMutationVariables, UpdateTopicMutation, UpdateTopicMutationVariables, } from '@user-office-software-libs/shared-types'; @@ -73,6 +75,15 @@ const setActiveTemplate = ( return cy.wrap(request); }; +const updateProposalPdfTemplate = ( + updateProposalPdfTemplateInput: UpdateProposalPdfTemplateMutationVariables +): Cypress.Chainable => { + const api = getE2EApi(); + const request = api.updateProposalPdfTemplate(updateProposalPdfTemplateInput); + + return cy.wrap(request); +}; + const typeToMenuTitle = new Map(); typeToMenuTitle.set('proposal', 'Proposal'); typeToMenuTitle.set('sample', 'Sample declaration'); @@ -810,3 +821,5 @@ Cypress.Commands.add( ); Cypress.Commands.add('setActiveTemplate', setActiveTemplate); + +Cypress.Commands.add('updateProposalPdfTemplate', updateProposalPdfTemplate); diff --git a/apps/e2e/cypress/types/template.d.ts b/apps/e2e/cypress/types/template.d.ts index 5e16ceb86a..6f4d024054 100644 --- a/apps/e2e/cypress/types/template.d.ts +++ b/apps/e2e/cypress/types/template.d.ts @@ -21,6 +21,8 @@ import { UpdateQuestionTemplateRelationSettingsMutationVariables, UpdateQuestionTemplateRelationSettingsMutation, SetActiveTemplateMutationVariables, + UpdateProposalPdfTemplateMutationVariables, + UpdateProposalPdfTemplateMutation, } from '@user-office-software-libs/shared-types'; declare global { @@ -185,6 +187,7 @@ declare global { options?: { url?: string; jsonPath?: string; + useBaseDomain: boolean; isMultipleSelect?: boolean; type?: 'radio' | 'dropdown'; firstTopic?: boolean; @@ -315,6 +318,7 @@ declare global { createTemplate: ( createTemplateInput: CreateTemplateMutationVariables ) => Cypress.Chainable; + /** * Clone template * @@ -397,6 +401,18 @@ declare global { setActiveTemplate: ( setActiveTemplateInput: SetActiveTemplateMutationVariables ) => Cypress.Chainable; + + /** + * Updates proposal PDF template + * + * @returns {typeof updateProposalPdfTemplate} + * @memberof Chainable + * @example + * cy.updateProposalPdfTemplate(updateProposalPdfTemplateInput: UpdateProposalPdfTemplateMutationVariables) + */ + updateProposalPdfTemplate: ( + updateProposalPdfTemplateInput: UpdateProposalPdfTemplateMutationVariables + ) => Cypress.Chainable; } } } diff --git a/apps/frontend/Dockerfile b/apps/frontend/Dockerfile index 1490a35e44..351f043627 100644 --- a/apps/frontend/Dockerfile +++ b/apps/frontend/Dockerfile @@ -23,7 +23,7 @@ COPY apps/frontend/ /app/frontend RUN npm run build # Stage 1, based on Nginx, to have only the compiled app, ready for production with Nginx -FROM nginxinc/nginx-unprivileged:1.25-alpine +FROM nginxinc/nginx-unprivileged:alpine3.23 COPY --from=build-stage /app/frontend/build/ /usr/share/nginx/html COPY --from=build-stage /app/frontend/build-version.txt /usr/share/nginx/html diff --git a/apps/frontend/default.conf b/apps/frontend/default.conf index d193267366..fefe0925cf 100644 --- a/apps/frontend/default.conf +++ b/apps/frontend/default.conf @@ -1,6 +1,7 @@ server { listen 8080; - + root /usr/share/nginx/html; + gzip on; gzip_disable "msie6"; @@ -10,9 +11,17 @@ server { gzip_http_version 1.1; gzip_types text/plain application/javascript text/css application/json application/x-javascript text/xml application/xml application/xml+rss text/javascript application/vnd.ms-fontobject application/x-font-ttf font/opentype; + # nginx's bundled mime.types has no mapping for .mjs, so ES module workers + # (e.g. pdf.worker.min.mjs) would be served as application/octet-stream and + # rejected by the browser's strict module MIME check. Set the type for .mjs + # only, without redefining the whole types map (a `types { }` block would + # REPLACE the inherited mime.types and break .html/.css serving). + location ~ \.mjs$ { + default_type text/javascript; + } + location / { - root /usr/share/nginx/html; index index.html index.htm; try_files $uri $uri/ /index.html =200; } -} \ No newline at end of file +} diff --git a/apps/frontend/src/components/common/UserManagementTable.tsx b/apps/frontend/src/components/common/UserManagementTable.tsx index 154b71ca9c..7eddfdb066 100644 --- a/apps/frontend/src/components/common/UserManagementTable.tsx +++ b/apps/frontend/src/components/common/UserManagementTable.tsx @@ -27,6 +27,8 @@ export type UserManagementTableProps = { title: string; addButtonLabel?: string; addButtonTooltip?: string; + /** Header shown at the top of the invite/selector modal */ + addModalTitle?: string; /** Disable the add button */ disabled?: boolean; /** Custom actions to be passed to PeopleTable */ @@ -45,6 +47,7 @@ const UserManagementTable = ({ title, addButtonLabel = 'Add', addButtonTooltip = 'Add a participant', + addModalTitle, disabled = false, onUserAction, excludeUserIds = [], @@ -78,7 +81,7 @@ const UserManagementTable = ({ const InviteComponent = ( setOpen(false)} onAddParticipants={handleAddParticipants} excludeUserIds={[...users.map((user) => user.id), ...excludeUserIds]} @@ -157,10 +160,9 @@ const UserManagementTable = ({ @@ -170,21 +172,25 @@ const UserManagementTable = ({ color: 'grey', paddingRight: '10px', display: 'inline-block', + whiteSpace: 'nowrap', + mt: '4px', }} > Invited: - {invites.map((invite) => ( - : } - size="small" - label={invite.email} - key={invite.email} - onDelete={() => handleDeleteInvite(invite)} - /> - ))} + + {invites.map((invite) => ( + : } + size="small" + label={invite.email} + key={invite.email} + onDelete={() => handleDeleteInvite(invite)} + /> + ))} + )} [] = [ title: 'Title', field: 'proposal.title', }, + { + title: 'Principal Investigator', + render: (rowData) => { + return getFullUserName(rowData.proposal.proposer); + }, + customSort(a, b) { + const name1 = getFullUserName(a.proposal.proposer); + const name2 = getFullUserName(b.proposal.proposer); + + return name1 < name2 ? -1 : 1; + }, + }, { title: 'Status', field: 'proposal.status.name', @@ -93,6 +106,8 @@ const FapProposalColumns: Column[] = [ { title: 'Reviewers', render: (data) => data.assignments?.length, + customSort: (a, b) => + (a.assignments?.length || 0) - (b.assignments?.length || 0), }, { title: 'Reviews', @@ -106,6 +121,29 @@ const FapProposalColumns: Column[] = [ return totalReviews === 0 ? '-' : `${countReviews} / ${totalReviews}`; }, + customSort: (a, b) => { + const totalReviewsA = a.assignments?.length || 0; + const totalReviewsB = b.assignments?.length || 0; + const gradedProposalsA = + a.assignments?.filter( + (assignment) => + assignment.review !== null && assignment.review.grade !== null + ).length || 0; + const gradedProposalsB = + b.assignments?.filter( + (assignment) => + assignment.review !== null && assignment.review.grade !== null + ).length || 0; + + const incompleteReviewsA = totalReviewsA - gradedProposalsA; + const incompleteReviewsB = totalReviewsB - gradedProposalsB; + + if (incompleteReviewsA === incompleteReviewsB) { + return totalReviewsB - totalReviewsA; + } + + return incompleteReviewsA - incompleteReviewsB; + }, }, { title: 'Average grade', @@ -459,6 +497,7 @@ const FapProposalsAndAssignmentsTable = ({ ]} actions={tableActions} options={{ + columnsButton: true, search: true, selection: true, pageSize: pageSize ? +pageSize : Math.min(10, maxPageLength), diff --git a/apps/frontend/src/components/proposal/CoProposers.tsx b/apps/frontend/src/components/proposal/CoProposers.tsx index bcd2f38597..f7ab1cfe3d 100644 --- a/apps/frontend/src/components/proposal/CoProposers.tsx +++ b/apps/frontend/src/components/proposal/CoProposers.tsx @@ -41,6 +41,7 @@ const CoProposers = ({ {...props} title="Co-Proposers" addButtonTooltip="Add a co-proposer" + addModalTitle="Add co-proposers" onUserAction={handleUserAction} excludeUserIds={proposer ? [proposer.id] : []} // QuickFix for material table changing immutable state diff --git a/apps/frontend/src/components/proposal/DataAccessUsersModal.tsx b/apps/frontend/src/components/proposal/DataAccessUsersModal.tsx index 60df81b840..b5bef8b796 100644 --- a/apps/frontend/src/components/proposal/DataAccessUsersModal.tsx +++ b/apps/frontend/src/components/proposal/DataAccessUsersModal.tsx @@ -87,6 +87,7 @@ const DataAccessUsersModal = ({ setInvites={() => {}} title="Data access users" addButtonLabel="Add Data Access User" + addModalTitle="Add Data Access User" excludeUserIds={excludeUserIds} /> diff --git a/apps/frontend/src/components/proposalBooking/CreateUpdateVisit.tsx b/apps/frontend/src/components/proposalBooking/CreateUpdateVisit.tsx index 38f70bd478..66c2e0c449 100644 --- a/apps/frontend/src/components/proposalBooking/CreateUpdateVisit.tsx +++ b/apps/frontend/src/components/proposalBooking/CreateUpdateVisit.tsx @@ -93,6 +93,7 @@ function CreateUpdateVisit({ event, close }: CreateUpdateVisitProps) { { diff --git a/apps/frontend/src/components/questionary/questionaryComponents/DynamicMultipleChoice/QuestionDynamicMultipleChoiceForm.tsx b/apps/frontend/src/components/questionary/questionaryComponents/DynamicMultipleChoice/QuestionDynamicMultipleChoiceForm.tsx index a453e0c2ee..68222bb28b 100644 --- a/apps/frontend/src/components/questionary/questionaryComponents/DynamicMultipleChoice/QuestionDynamicMultipleChoiceForm.tsx +++ b/apps/frontend/src/components/questionary/questionaryComponents/DynamicMultipleChoice/QuestionDynamicMultipleChoiceForm.tsx @@ -21,7 +21,7 @@ import InputLabel from '@mui/material/InputLabel'; import { SelectChangeEvent } from '@mui/material/Select'; import { styled } from '@mui/material/styles'; import { Field } from 'formik'; -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import * as Yup from 'yup'; import CheckboxWithLabel from 'components/common/FormikUICheckboxWithLabel'; @@ -34,6 +34,7 @@ import { ApiCallRequestHeader, DynamicMultipleChoiceConfig, } from 'generated/sdk'; +import { useDataApi } from 'hooks/common/useDataApi'; import { urlValidationSchema } from 'utils/helperFunctions'; import { useNaturalKeySchema } from 'utils/userFieldValidationSchema'; @@ -83,6 +84,14 @@ const jsonPathFieldsDocRows = [ }, ]; +const pathNameValidationSchema = Yup.string() + .matches( + /^(?!http|www)/i, + 'Provide a valid pathname, the base domain is already provided' + ) + .matches(/^(?!\/)/, 'Leading slash should not be included') + .required('Pathname is required'); + const CustomizedTableCell = styled(TableCell)(({ theme }) => ({ [`&.${tableCellClasses.head}`]: { background: theme.palette.common.black, @@ -110,6 +119,9 @@ export const QuestionDynamicMultipleChoiceForm = (props: QuestionFormProps) => { const urlValidation = urlValidationSchema(); const [showIsMultipleSelectCheckbox, setShowIsMultipleSelectCheckbox] = useState(config.variant === 'dropdown'); + const [useBaseDomain, setUseBaseDomain] = useState( + config.useBaseDomain ?? false + ); const availableVariantOptions = [ { label: 'Radio', value: 'radio' }, @@ -119,6 +131,25 @@ export const QuestionDynamicMultipleChoiceForm = (props: QuestionFormProps) => { const [isJsonPathFieldDocPopupOpen, setIsJsonPathFieldDocPopupOpen] = useState(false); + const [isBaseURLCheckBoxPopupOpen, setIsBaseURLCheckBoxPopupOpen] = + useState(false); + + const api = useDataApi(); + const [serverConfig, setServerConfig] = useState(null); + + useEffect(() => { + const fetchConfig = async () => { + try { + const { ServerConfig } = await api().getServerConfig(); + setServerConfig(ServerConfig.baseURL); + } catch (err) { + console.error('Failed to fetch server config', err); + } + }; + + fetchConfig(); + }, [api]); + return ( { config: Yup.object({ required: Yup.bool(), variant: Yup.string().required('Variant is required'), - url: urlValidation, + + url: Yup.string().when('useBaseDomain', { + is: true, + then: (schema) => schema.concat(pathNameValidationSchema), + otherwise: (schema) => schema.concat(urlValidation), + }), + + useBaseDomain: Yup.bool(), jsonPath: Yup.string(), apiRequestHeaders: Yup.array(), }), })} > - {() => ( + {({ setFieldValue }) => ( <> { Link - - - - +
+ {useBaseDomain && ( + + {`${serverConfig}/`} + + )} + + +
+
+ ) => { + const checked = e.target.checked; + setUseBaseDomain(checked); + setFieldValue('config.useBaseDomain', checked); + }} + /> + + setIsBaseURLCheckBoxPopupOpen(true)} + > + + + setIsBaseURLCheckBoxPopupOpen(false)} + aria-labelledby="customized-dialog-title" + > + +
+ Instead of providing a full URL to retrieve a list for + your question, this option allows you to use the current + server's domain and specify only the relative path. +
+
+ This is particularly useful for GraphQL queries that + access the database. Please note that while the domain + displayed in the UI reflects the current deployment, it + is not stored. At runtime, the system will automatically + resolve and apply the current servers domain. +
+
+ + + +
+
+
+
+ + JsonPath { const config = props.questionRel.config as DynamicMultipleChoiceConfig; const [showIsMultipleSelectCheckbox, setShowIsMultipleSelectCheckbox] = useState(config.variant === 'dropdown'); + const [useBaseDomain, setUseBaseDomain] = useState( + config.useBaseDomain ?? false + ); + const [isBaseURLCheckBoxPopupOpen, setIsBaseURLCheckBoxPopupOpen] = + useState(false); const availableVariantOptions = [ { label: 'Radio', value: 'radio' }, @@ -39,6 +62,22 @@ export const QuestionTemplateRelationDynamicMultipleChoiceForm = ( const urlValidation = urlValidationSchema(); + const api = useDataApi(); + const [serverConfig, setServerConfig] = useState(null); + + useEffect(() => { + const fetchConfig = async () => { + try { + const { ServerConfig } = await api().getServerConfig(); + setServerConfig(ServerConfig.baseURL); + } catch (err) { + console.error('Failed to fetch server config', err); + } + }; + + fetchConfig(); + }, [api]); + return ( schema.concat(pathNameValidationSchema), + otherwise: (schema) => schema.concat(urlValidation), + }), }), })} > - {() => ( + {({ setFieldValue }) => ( <> @@ -103,17 +146,94 @@ export const QuestionTemplateRelationDynamicMultipleChoiceForm = ( Link - + +
+ {useBaseDomain && ( + + {`${serverConfig}/`} + + )} + + +
+ +
+ ) => { + const checked = e.target.checked; + setUseBaseDomain(checked); + setFieldValue('config.useBaseDomain', checked); + }} + /> + + setIsBaseURLCheckBoxPopupOpen(true)} + > + + + setIsBaseURLCheckBoxPopupOpen(false)} + aria-labelledby="customized-dialog-title" + > + +
+ Instead of providing a full URL to retrieve a list for + your question, this option allows you to use the current + server's domain and specify only the relative path. +
+
+ This is particularly useful for GraphQL queries that + access the database. Please note that while the domain + displayed in the UI reflects the current deployment, it + is not stored. At runtime, the system will automatically + resolve and apply the current servers domain. +
+
+ + + +
+
+
- - + + + JsonPath { height: '1000px', }} ref={setContainerRef} + data-cy="pdf-template-preview" > - + + Failed to load PDF file. + + } + > {Array.from(new Array(numPages), (_el, index) => (