diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml deleted file mode 100644 index 0f6324ac..00000000 --- a/.github/dependabot.yaml +++ /dev/null @@ -1,22 +0,0 @@ -version: 2 - -updates: - # Enable version updates for npm - - package-ecosystem: "npm" - # Target non-default branch - target-branch: "development" - # Look for `package.json` and `lock` files in the `root` directory - directory: "/" - # Check the npm registry for updates once a week - schedule: - interval: "weekly" - - # Enable version updates for Docker - - package-ecosystem: "docker" - # Target non-default branch - target-branch: "development" - # Look for a `Dockerfile` in the `root` directory - directory: "/" - # Check for updates once a week - schedule: - interval: "weekly" diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b2420a0..c45e0d64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,17 @@ ## v1.1.0 ### Added +- Added `fetchAnswerCustomQuestion` to `Plan` model in order to get answered counts for custom questions [#161] +- Added `publishedCustomQuestion` query resolver and added to schema [#173] +- Added `ON DELETE CASCADE` sql migration script for deletion of `versionedTemplateCustomization` and `templateCustomization` FKs [#171] +- Added `findByPosition` function to `CustomQuestion` to assist with reordering of custom questions [#171] +- Added `findActiveByTemplateAffiliationAndQuestion` to `VersionedQuestionCustomization` and `findActiveByTemplateAffiliationAndSection` to `VersionedSectionCustomization` to help surface custom guidance [#171] +- Added `MoveCustomQuestionDirection` enum to `questionCustomization` schema [#171] +- Added new `templateCustomizationPublishHelpers.ts` service to help update snapshot child customization records when publishing a templateCustomization [#171] +- Added `versionedCustomSectionId` and `versionedCustomQuestionId` files to `answers` table [#159] +- Added `findByVersionedCustomSectionId` and `findByVersionedSectionIdAndType` functions to VersionedCustomQuestion model [#159] +- Added `versionedCustomSection` and `versionedCustomQuestion` chained resolvers to `answer` query [#159] +- Added logic to set a default output type for each entry in a `ResearchOutputTable` answer. - Added `guidanceText` and `sampleText` fields to `addQuestionCustomization` and added `json`, `questionText`, `requirementText`, `guidanceText`, `sampleText`, `useSampleTextAsDefault` and `required` to `addCustomQuestionInput` [#130] - Added `questionCustomizationByVersionedQuestion` resolver [#130] - Added `findByCustomizationAndVersionedQuestion` method to `QuestionCustomization` model [#130] @@ -47,6 +58,24 @@ - added data-migration to fix question JSON so that `"selected": 0` is now `"selected": false` (and `1` -> `true`). ### Updated +- Updated `nodemailer` +- Updated `relatedWorksTables.spec.ts` to fix linter issues +- Updated `PlanProgress.findByPlanId` to use `fetchAnswerCustomQuestions` and return answered question counts that include the custom questions [#161] +- Updated `PlanProgress.findByPlanId` to reuse the `PlanSectionProgress.findByPlanId` function to return `totalQuestions` and `answeredQuestions` [#161] +- Updated the `batch_update_related_works` stored procedure to operate on batches of DMPs and improve speed of stored procedure by joining based on plan ID rather than plan ID or DMP DOI. Updated the unit tests to use `@testcontainers/mysql` so that they can run in CI, are self-contained and don't affect local tables. +- Updated `answerByVersionedQuestionId` query and `addAnswer` mutation to pass in `versionedCustomSectionId` and `versionedCustomQuestionid` variables so that we can get and save the correct answers [#173] +- Updated sql query in `findActiveByTemplateAffiliationAndQuestion` function in `VersionedQuestionCustomization` model to include `affiliationId` so that we can get the name of the org that made the customization [#173] +- Updated `publishedQuestion` resolver in `versionedQuestion.ts` to return customization info, including customized sample answer and name of org that made the customization [#173] +- Updated `VersionedQuestion` schema to include `customizationId`, `customizationGuidanceText`, `customizationSampleText` and `customizationOwnerAffiliation` fields [#173] +- Updated `guidanceSourcesForPlan` query to pass in `customQuestionId` and updated `getGuidanceSourcesForPlan` service to add `customQuestion` guidance [#173] +- Updated renovate config to rebase when behind the base branch +- Updated `Answer` model with `versionedCustomSectionId` and `versionedCustomQuestionId`, and added new `findFilledAnswersByCustomQuestionIds` and `findByPlanIdAndVersionedCustomQuestionId` functions, and updated `isValid` function to account for new id fields, and updated `create` function to check both `versionedQuestionId` and `versionedCustomQuestionId` for already existing answer [#159] +- Updated `publishedQuestions` query in `versionedQuestion` resolver to return both `BASE` and `CUSTOM` versioned questions for the given versionedSectionId. Also, added `publishedCustomQuestions` query to return the `customQuestions` associated with a `customSection` [#159] +- Updated `PlanSectionProgress` so that it returns custom sections, and correct totalQuestions counts that include customQuestions [#167] +- Updated `PlanSearchResult` `versionedSections` chained resolver to include `versionedTemplateId` [#167] +- Updated dependencies based on Renovate PRs +- Updated the `VersionedTemplateSearchResult` to return `versionedTemplateCustomizationId` for the `search` function [#166] +- Update `domain` to `emailDomain` in `AffiliationEmailDomain` model to match field name in table - Update buildspec to use `--omit=dev` instead of `--production` on `npm` commands - Updated `re3data-os-populate.ts` because `fetchWithTimeout` kept erroring out when we populate the OpenSearch data. We needed to add a `catch` and `retries` because a single failed attempt would kill the entire sync. [#118] - Moved the data migration script that populates the researchOutputTypes db table to `local-only` directory, because it was not @@ -86,6 +115,8 @@ - Updates to appease newer version of eslint ### Removed +- Removed old dependabot config +- Removed override for `minimatch` and `immutable` dependencies - Removed the `unique_vTemplateCusts` restriction from `versionedTemplateCustomizations` table, because it was not allowing the publishing of a templateCustomization more than twice, because the combination of `templateCustomizationId` and `active` had to be unique [#428] - Removed `src/datasources/dynamo` data source. Writes to Dynamo are now being handled by the `generateMaDMPRecord` Lambda Function. - Removed `src/models/PlanVersion` @@ -105,6 +136,10 @@ - In `preparePaginationOptions` function, wrapped each cursorField with `COALESCE` to handle `NULL` values in SQL `CONCAT`, otherwise if any cursorField is NULL, it would just return a null value due to the way `CONCAT` works [#107] - Fixed breaking cloning of template. The `addTemplate` was updated to accept a `copyFromVersionedTemplateId` so that we copy from versioned template, section and questions, when it's not a template from the user's org. Otherwise we check for `copyFromTemplateId` to copy/clone from templates, sections and questions, and if neither are present, we continue to create a new record for `templates` table [#1006] - Fixed issue with templates not cloning with sections and questions by updating the `addTemplate` mutation to clone from non-versioned template, section and question [#1006] + +### Chore +- Added override for `lodash` to `4.18.1` to address high vulnerability issue + ## v1.0 ### Updated diff --git a/data-migrations/2026-03-30-alter-answers-table-for-customQuestions.sql b/data-migrations/2026-03-30-alter-answers-table-for-customQuestions.sql new file mode 100644 index 00000000..83152517 --- /dev/null +++ b/data-migrations/2026-03-30-alter-answers-table-for-customQuestions.sql @@ -0,0 +1,30 @@ +ALTER TABLE `answers` + MODIFY `versionedSectionId` int unsigned NULL, + MODIFY `versionedQuestionId` int unsigned NULL, + ADD COLUMN `versionedCustomSectionId` int unsigned NULL, + ADD COLUMN `versionedCustomQuestionId` int unsigned NULL, + ADD KEY `versionedCustomSectionId` (`versionedCustomSectionId`), + ADD KEY `versionedCustomQuestionId` (`versionedCustomQuestionId`), + ADD CONSTRAINT `answers_ibfk_6` + FOREIGN KEY (`versionedCustomSectionId`) + REFERENCES `versionedCustomSections` (`id`) ON DELETE CASCADE, + ADD CONSTRAINT `answers_ibfk_7` + FOREIGN KEY (`versionedCustomQuestionId`) + REFERENCES `versionedCustomQuestions` (`id`) ON DELETE CASCADE, + -- Answer can be linked to either a question or a custom question, but not both. This constraint ensures that only + -- one of the pairs (versionedSectionId, versionedQuestionId) or (versionedCustomSectionId, versionedCustomQuestionId) can be non-null at any given time. + ADD CONSTRAINT `chk_answer_question_type` CHECK ( + ( + versionedSectionId IS NOT NULL AND + versionedQuestionId IS NOT NULL AND + versionedCustomSectionId IS NULL AND + versionedCustomQuestionId IS NULL + ) + OR + ( + versionedCustomSectionId IS NOT NULL AND + versionedCustomQuestionId IS NOT NULL AND + versionedSectionId IS NULL AND + versionedQuestionId IS NULL + ) + ); \ No newline at end of file diff --git a/data-migrations/2026-04-02-0154-add-cascade-to-versioned-sections-and-questions.sql b/data-migrations/2026-04-02-0154-add-cascade-to-versioned-sections-and-questions.sql new file mode 100644 index 00000000..e843d859 --- /dev/null +++ b/data-migrations/2026-04-02-0154-add-cascade-to-versioned-sections-and-questions.sql @@ -0,0 +1,41 @@ +-- Add ON DELETE CASCADE to versionedTemplateCustomizationId FKs on all four +-- child tables. Previously these had no cascade, requiring manual deletion +-- in application code when rolling back a published snapshot. +-- +-- Note: MariaDB requires DROP and ADD of a same-named FK constraint to be +-- in separate ALTER TABLE statements to avoid a duplicate constraint name error. + +ALTER TABLE versionedSectionCustomizations + DROP FOREIGN KEY fk_vSectionCust_templateCustId; +ALTER TABLE versionedSectionCustomizations + ADD CONSTRAINT fk_vSectionCust_templateCustId_cascade + FOREIGN KEY (versionedTemplateCustomizationId) + REFERENCES versionedTemplateCustomizations(id) + ON DELETE CASCADE; + + +ALTER TABLE versionedQuestionCustomizations + DROP FOREIGN KEY fk_vQCust_templateCustId; +ALTER TABLE versionedQuestionCustomizations + ADD CONSTRAINT fk_vQCust_templateCustId_cascade + FOREIGN KEY (versionedTemplateCustomizationId) + REFERENCES versionedTemplateCustomizations(id) + ON DELETE CASCADE; + + +ALTER TABLE versionedCustomSections + DROP FOREIGN KEY fk_vCustomSecs_templateCustId; +ALTER TABLE versionedCustomSections + ADD CONSTRAINT fk_vCustomSecs_templateCustId_cascade + FOREIGN KEY (versionedTemplateCustomizationId) + REFERENCES versionedTemplateCustomizations(id) + ON DELETE CASCADE; + + +ALTER TABLE versionedCustomQuestions + DROP FOREIGN KEY fk_vCustomQs_templateCustId; +ALTER TABLE versionedCustomQuestions + ADD CONSTRAINT fk_vCustomQs_templateCustId_cascade + FOREIGN KEY (versionedTemplateCustomizationId) + REFERENCES versionedTemplateCustomizations(id) + ON DELETE CASCADE; diff --git a/data-migrations/2026-04-02-0756-add-cascade-to-template-customizations.sql b/data-migrations/2026-04-02-0756-add-cascade-to-template-customizations.sql new file mode 100644 index 00000000..6da78931 --- /dev/null +++ b/data-migrations/2026-04-02-0756-add-cascade-to-template-customizations.sql @@ -0,0 +1,55 @@ +-- Add ON DELETE CASCADE to ensure that when a template customization is deleted, +-- all associated versioned records are also deleted so that we don't have orphaned versioned records that reference deleted template customizations. +ALTER TABLE customQuestions + DROP FOREIGN KEY fk_customQs_templateCustId; +ALTER TABLE customQuestions + ADD CONSTRAINT fk_customQs_templateCustId_cascade + FOREIGN KEY (templateCustomizationId) REFERENCES templateCustomizations(id) ON DELETE CASCADE; + +ALTER TABLE customSections + DROP FOREIGN KEY fk_customSecs_templateCustId; +ALTER TABLE customSections + ADD CONSTRAINT fk_customSecs_templateCustId_cascade + FOREIGN KEY (templateCustomizationId) REFERENCES templateCustomizations(id) ON DELETE CASCADE; + +ALTER TABLE questionCustomizations + DROP FOREIGN KEY fk_qCust_templateCustId; +ALTER TABLE questionCustomizations + ADD CONSTRAINT fk_qCust_templateCustId_cascade + FOREIGN KEY (templateCustomizationId) REFERENCES templateCustomizations(id) ON DELETE CASCADE; + +ALTER TABLE sectionCustomizations + DROP FOREIGN KEY fk_sectionCust_templateCustId; +ALTER TABLE sectionCustomizations + ADD CONSTRAINT fk_sectionCust_templateCustId_cascade + FOREIGN KEY (templateCustomizationId) REFERENCES templateCustomizations(id) ON DELETE CASCADE; + +ALTER TABLE versionedTemplateCustomizations + DROP FOREIGN KEY fk_vTemplateCust_templateId; +ALTER TABLE versionedTemplateCustomizations + ADD CONSTRAINT fk_vTemplateCust_templateId_cascade + FOREIGN KEY (templateCustomizationId) REFERENCES templateCustomizations(id) ON DELETE CASCADE; + +ALTER TABLE versionedCustomQuestions + DROP FOREIGN KEY fk_vCustomQs_questionId; +ALTER TABLE versionedCustomQuestions + ADD CONSTRAINT fk_vCustomQs_questionId_cascade + FOREIGN KEY (customQuestionId) REFERENCES customQuestions(id) ON DELETE CASCADE; + +ALTER TABLE versionedCustomSections + DROP FOREIGN KEY fk_vCustomSecs_sectionId; +ALTER TABLE versionedCustomSections + ADD CONSTRAINT fk_vCustomSecs_sectionId_cascade + FOREIGN KEY (customSectionId) REFERENCES customSections(id) ON DELETE CASCADE; + +ALTER TABLE versionedQuestionCustomizations + DROP FOREIGN KEY fk_vQCust_questionId; +ALTER TABLE versionedQuestionCustomizations + ADD CONSTRAINT fk_vQCust_questionId_cascade + FOREIGN KEY (questionCustomizationId) REFERENCES questionCustomizations(id) ON DELETE CASCADE; + +ALTER TABLE versionedSectionCustomizations + DROP FOREIGN KEY fk_vSectionCust_sectionId; +ALTER TABLE versionedSectionCustomizations + ADD CONSTRAINT fk_vSectionCust_sectionId_cascade + FOREIGN KEY (sectionCustomizationId) REFERENCES sectionCustomizations(id) ON DELETE CASCADE; \ No newline at end of file diff --git a/data-migrations/2026-04-08-update-chk-answer-question-type.sql b/data-migrations/2026-04-08-update-chk-answer-question-type.sql new file mode 100644 index 00000000..684ff6b7 --- /dev/null +++ b/data-migrations/2026-04-08-update-chk-answer-question-type.sql @@ -0,0 +1,31 @@ +-- The answers table now includes versionedCustomSectionId and versionedCustomQuestionId. We can have three valid combinations of these four fields: +-- 1) Base question in a base section: versionedSectionId and versionedQuestionId are non-null, versionedCustomSectionId and versionedCustomQuestionId are null +-- 2) Custom question in a custom section: versionedCustomSectionId and versionedCustomQuestionId are non-null, versionedSectionId and versionedQuestionId are null +-- 3) Custom question pinned to a base section: versionedSectionId and versionedCustomQuestionId are non-null, versionedQuestionId and versionedCustomSectionId are null +ALTER TABLE `answers` + DROP CONSTRAINT `chk_answer_question_type`, + ADD CONSTRAINT `chk_answer_question_type` CHECK ( + -- Base question in a base section + ( + versionedSectionId IS NOT NULL AND + versionedQuestionId IS NOT NULL AND + versionedCustomSectionId IS NULL AND + versionedCustomQuestionId IS NULL + ) + OR + -- Custom question in a custom section + ( + versionedCustomSectionId IS NOT NULL AND + versionedCustomQuestionId IS NOT NULL AND + versionedSectionId IS NULL AND + versionedQuestionId IS NULL + ) + OR + -- Custom question pinned to a base section + ( + versionedSectionId IS NOT NULL AND + versionedCustomQuestionId IS NOT NULL AND + versionedQuestionId IS NULL AND + versionedCustomSectionId IS NULL + ) + ); diff --git a/data-migrations/2026-04-10-1600-refactor-stored-procs.sql b/data-migrations/2026-04-10-1600-refactor-stored-procs.sql new file mode 100644 index 00000000..0d90639a --- /dev/null +++ b/data-migrations/2026-04-10-1600-refactor-stored-procs.sql @@ -0,0 +1,252 @@ +DROP PROCEDURE IF EXISTS create_related_works_staging_tables; +DROP PROCEDURE IF EXISTS batch_update_related_works; +DROP PROCEDURE IF EXISTS cleanup_orphan_works; + +DELIMITER $$ + +-- Creates the two temporary staging tables that callers populate before +-- calling batch_update_related_works. +CREATE PROCEDURE `create_related_works_staging_tables`() +BEGIN + DROP TEMPORARY TABLE IF EXISTS stagingWorkVersions; + CREATE TEMPORARY TABLE stagingWorkVersions + ( + `doi` VARCHAR(255) NOT NULL PRIMARY KEY, + `hash` BINARY(16) NOT NULL, + `workType` VARCHAR(255) NOT NULL, + `publicationDate` DATE NULL, + `title` TEXT NULL, + `abstractText` MEDIUMTEXT NULL, + `authors` JSON NOT NULL, + `institutions` JSON NOT NULL, + `funders` JSON NOT NULL, + `awards` JSON NOT NULL, + `publicationVenue` VARCHAR(1000) NULL, + `sourceName` VARCHAR(255) NOT NULL, + `sourceUrl` VARCHAR(255) NOT NULL + ) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_0900_ai_ci; + + DROP TEMPORARY TABLE IF EXISTS stagingRelatedWorks; + CREATE TEMPORARY TABLE stagingRelatedWorks + ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + `planId` INT UNSIGNED NOT NULL, + `workDoi` VARCHAR(255) NOT NULL, + `hash` BINARY(16) NOT NULL, + `sourceType` VARCHAR(32) NOT NULL, + `score` FLOAT NOT NULL, + `status` VARCHAR(255) NOT NULL, + `scoreMax` FLOAT NOT NULL, + `doiMatch` JSON NOT NULL, + `contentMatch` JSON NOT NULL, + `authorMatches` JSON NOT NULL, + `institutionMatches` JSON NOT NULL, + `funderMatches` JSON NOT NULL, + `awardMatches` JSON NOT NULL, + + INDEX (`planId`, `workDoi`), + CONSTRAINT unique_plan_work UNIQUE (`planId`, `workDoi`) + ) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_0900_ai_ci; +END$$ + +DELIMITER ; + +DELIMITER $$ + +-- Upserts staging data into works, workVersions, and relatedWorks within a transaction. +-- +-- Flow: +-- 1. Insert new works and workVersions (deduplicated by DOI and hash). +-- 2. Resolve staging rows to real foreign keys (planId, workVersionId). +-- 3. Insert new relatedWorks links for plans that don't already link to a given DOI. +-- 4. Update existing relatedWorks links (matched by DOI, not workVersionId) +-- with the latest staging data (behaviour depends on mode: see below). +-- 5. (System mode only) Delete stale PENDING links no longer present in this batch. +-- +-- Modes: +-- systemMatched = FALSE (user-triggered): only syncs the status field on existing links. +-- systemMatched = TRUE (system re-match): overwrites all scoring fields on PENDING rows, +-- if a new workVersion was crated in step 1 the workVersionId is updated, +-- and garbage-collects stale PENDING links no longer present in this batch. +CREATE PROCEDURE `batch_update_related_works`(IN systemMatched BOOLEAN) +BEGIN + DECLARE EXIT HANDLER FOR SQLEXCEPTION + BEGIN + ROLLBACK; + RESIGNAL; + END; + + START TRANSACTION; + + -- works: register any new DOIs, skips if DOI already exists (unique_doi constraint) + INSERT IGNORE INTO works (doi) + SELECT doi + FROM stagingWorkVersions; + + -- workVersions: insert new version snapshots. Skips if this (workId, hash) pair + -- already exists (unique_hash composite constraint). workId is resolved via s.doi = w.doi. + INSERT IGNORE INTO workVersions (workId, hash, workType, publicationDate, title, + abstractText, authors, institutions, funders, + awards, publicationVenue, sourceName, + sourceUrl) + SELECT w.id, + s.hash, + s.workType, + s.publicationDate, + s.title, + s.abstractText, + s.authors, + s.institutions, + s.funders, + s.awards, + s.publicationVenue, + s.sourceName, + s.sourceUrl + FROM stagingWorkVersions s + INNER JOIN works w ON s.doi = w.doi; + + DROP TEMPORARY TABLE IF EXISTS resolvedStagingLinks; + CREATE TEMPORARY TABLE resolvedStagingLinks + ( + `id` INT UNSIGNED NOT NULL, + `planId` INT NOT NULL, + `workVersionId` INT UNSIGNED NOT NULL, + `workDoi` VARCHAR(255) NOT NULL, + UNIQUE KEY (`planId`, `workVersionId`) + ) + ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_0900_ai_ci; + + -- resolvedStagingLinks: map each staging row to its real foreign keys (planId, workVersionId) + -- by joining through works and workVersions on doi and hash. + -- The id column carries stagingRelatedWorks.id so we can join back to it later. + INSERT INTO resolvedStagingLinks (id, planId, workVersionId, workDoi) + SELECT s.id AS id, + p.id AS planId, + wv.id AS workVersionId, + s.workDoi + FROM stagingRelatedWorks s + JOIN plans p ON s.planId = p.id + JOIN works w ON s.workDoi = w.doi + JOIN workVersions wv ON wv.workId = w.id AND wv.hash = s.hash; + + -- relatedWorks: link works to plans. Deduplicates by (planId, DOI) rather than + -- relying on the table's unique_planId_workVersionId constraint, because a single + -- work can have multiple workVersions (different hashes) and we only want one + -- link per plan per work. + INSERT INTO relatedWorks (planId, workVersionId, sourceType, score, status, + scoreMax, doiMatch, contentMatch, authorMatches, + institutionMatches, funderMatches, awardMatches) + SELECT links.planId, + links.workVersionId, + s.sourceType, + s.score, + s.status, + s.scoreMax, + s.doiMatch, + s.contentMatch, + s.authorMatches, + s.institutionMatches, + s.funderMatches, + s.awardMatches + FROM resolvedStagingLinks links + JOIN stagingRelatedWorks s ON links.id = s.id + LEFT JOIN ( + relatedWorks r + JOIN workVersions wv ON r.workVersionId = wv.id + JOIN works w ON wv.workId = w.id + ) ON links.planId = r.planId AND links.workDoi = w.doi + WHERE r.id IS NULL; -- only insert if not already linked + + -- User-triggered match: only sync the status field, preserving existing scoring data. + -- <=> treats NULL = NULL as true. + IF systemMatched = FALSE THEN + UPDATE relatedWorks r + JOIN workVersions wv ON r.workVersionId = wv.id + JOIN works w ON wv.workId = w.id + JOIN resolvedStagingLinks links ON r.planId = links.planId AND w.doi = links.workDoi + JOIN stagingRelatedWorks s ON links.id = s.id + SET r.status = s.status + WHERE NOT (r.status <=> s.status); + END IF; + + -- System re-match: update PENDING rows with latest scoring data and workVersionId + -- (which may point to a newer version of the same work if its metadata hash changed) + IF systemMatched = TRUE THEN + UPDATE relatedWorks r + JOIN workVersions wv ON r.workVersionId = wv.id + JOIN works w ON wv.workId = w.id + JOIN resolvedStagingLinks links ON r.planId = links.planId AND w.doi = links.workDoi + JOIN stagingRelatedWorks s ON links.id = s.id + SET r.workVersionId = links.workVersionId, + r.sourceType = s.sourceType, + r.score = s.score, + r.scoreMax = s.scoreMax, + r.doiMatch = s.doiMatch, + r.contentMatch = s.contentMatch, + r.authorMatches = s.authorMatches, + r.institutionMatches = s.institutionMatches, + r.funderMatches = s.funderMatches, + r.awardMatches = s.awardMatches + WHERE r.status = 'PENDING'; + + -- affectedPlanIds: collect plans in this batch so the stale-link cleanup only touches relevant plans + DROP TEMPORARY TABLE IF EXISTS affectedPlanIds; + CREATE TEMPORARY TABLE affectedPlanIds ( + `planId` INT NOT NULL PRIMARY KEY + ) ENGINE = InnoDB; + INSERT INTO affectedPlanIds (planId) + SELECT DISTINCT planId FROM resolvedStagingLinks; + + -- Remove stale PENDING links for affected plans that were not present + -- in this batch. Joins on (planId, workVersionId), so if a work's hash + -- changed, the old version's link is deleted. + DELETE r + FROM relatedWorks r + JOIN affectedPlanIds ap ON r.planId = ap.planId + LEFT JOIN resolvedStagingLinks links ON r.planId = links.planId AND r.workVersionId = links.workVersionId + WHERE r.status = 'PENDING' + AND links.id IS NULL; + END IF; + + COMMIT; + +END$$ + +DELIMITER ; + +DELIMITER $$ + +-- cleanup_orphan_works: garbage-collect workVersions and works that are no longer +-- referenced by any relatedWorks row +CREATE PROCEDURE `cleanup_orphan_works`() +BEGIN + DECLARE EXIT HANDLER FOR SQLEXCEPTION + BEGIN + ROLLBACK; + RESIGNAL; + END; + + START TRANSACTION; + + -- Delete workVersions that are no longer referenced by any relatedWorks row + DELETE wv + FROM workVersions wv + LEFT JOIN relatedWorks r ON r.workVersionId = wv.id + WHERE r.id IS NULL; + + -- Delete works that have no remaining workVersions + DELETE w + FROM works w + LEFT JOIN workVersions wv ON wv.workId = w.id + WHERE wv.id IS NULL; + + COMMIT; +END$$ + +DELIMITER ; diff --git a/docker-compose.yaml b/docker-compose.yaml index ad7ea106..67b471ab 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -38,7 +38,7 @@ services: - "./docker/redis:/var/lib/redis" opensearch: - image: "opensearchproject/opensearch:1.3.0" + image: "opensearchproject/opensearch:1.3.20" container_name: opensearch environment: - cluster.name=opensearch-cluster # Name the cluster diff --git a/package-lock.json b/package-lock.json index 29bb2ce7..70c1533f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,24 +1,24 @@ { - "name": "dmsp_apollo", - "version": "1.0.0", + "name": "dmptool-apollo-server", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "dmsp_apollo", - "version": "1.0.0", + "name": "dmptool-apollo-server", + "version": "1.1.0", "license": "MIT", "dependencies": { "@apollo/datasource-rest": "^6.4.1", - "@apollo/server": "^5.4.0", + "@apollo/server": "^5.5.0", "@apollo/utils.keyvadapter": "^4.0.1", "@as-integrations/express5": "^1.1.2", - "@aws-sdk/client-dynamodb": "^3.1001.0", - "@aws-sdk/client-ssm": "^3.1001.0", - "@aws-sdk/credential-providers": "^3.1001.0", - "@aws-sdk/util-dynamodb": "^3.996.1", - "@dmptool/types": "^3.1.2", - "@dmptool/utils": "^1.0.41", + "@aws-sdk/client-dynamodb": "^3.1020.0", + "@aws-sdk/client-ssm": "^3.1020.0", + "@aws-sdk/credential-providers": "^3.1020.0", + "@aws-sdk/util-dynamodb": "^3.996.2", + "@dmptool/types": "^3.1.3", + "@dmptool/utils": "^1.0.43", "@elastic/ecs-pino-format": "^1.5.0", "@graphql-tools/merge": "^9.1.7", "@graphql-tools/mock": "^9.1.5", @@ -26,7 +26,7 @@ "@keyv/redis": "^5.1.6", "@node-oauth/express-oauth-server": "^4.1.5", "@opensearch-project/opensearch": "^3.5.1", - "@xmldom/xmldom": "^0.8.11", + "@xmldom/xmldom": "^0.9.9", "bcryptjs": "^3.0.3", "body-parser": "^2.2.2", "cookie-parser": "^1.4.7", @@ -34,14 +34,14 @@ "dotenv": "^17.3.1", "express": "^5.2.1", "express-jwt": "^8.5.1", - "graphql": "^16.13.0", + "graphql": "^16.13.2", "graphql-tag": "^2.12.6", "http-cache-semantics": "^4.2.0", "is-ci": "^4.1.0", "jsonwebtoken": "^9.0.3", "keyv": "^5.6.0", - "mysql2": "^3.18.2", - "nodemailer": "^8.0.1", + "mysql2": "^3.20.0", + "nodemailer": "^8.0.5", "pino": "^10.3.1", "uuid": "^9.0.1", "uuid-random": "^1.3.2", @@ -49,32 +49,32 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", - "@graphql-codegen/cli": "^6.1.3", + "@graphql-codegen/cli": "^6.2.1", "@graphql-codegen/typescript": "^5.0.9", "@graphql-codegen/typescript-resolvers": "^5.1.7", + "@testcontainers/mysql": "^11.13.0", "@types/cors": "^2.8.19", "@types/express": "^5.0.6", "@types/express-oauth-server": "^2.0.10", "@types/jest": "^30.0.0", "@types/oauth2-server": "^3.0.18", - "@types/pino": "^7.0.5", "@types/supertest": "^7.2.0", "assert": "^2.1.0", "casual": "^1.6.2", - "eslint": "^10.0.3", + "eslint": "^10.1.0", "eslint-formatter-unix": "^9.0.1", "globals": "^17.4.0", "husky": "^9.1.7", - "jest": "^30.2.0", + "jest": "^30.3.0", "jest-expect-message": "^1.1.3", - "jest-mock": "^30.2.0", + "jest-mock": "^30.3.0", "nock": "^14.0.11", "nodemon": "^3.1.14", "supertest": "^7.2.2", "ts-jest": "^29.4.6", "ts-node-dev": "^2.0.0", "typescript": "^5.9.3", - "typescript-eslint": "^8.56.1" + "typescript-eslint": "^8.58.0" }, "engines": { "node": ">=22.15.0", @@ -139,9 +139,9 @@ } }, "node_modules/@apollo/server": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/@apollo/server/-/server-5.4.0.tgz", - "integrity": "sha512-E0/2C5Rqp7bWCjaDh4NzYuEPDZ+dltTf2c0FI6GCKJA6GBetVferX3h1//1rS4+NxD36wrJsGGJK+xyT/M3ysg==", + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@apollo/server/-/server-5.5.0.tgz", + "integrity": "sha512-vWtodBOK/SZwBTJzItECOmLfL8E8pn/IdvP7pnxN5g2tny9iW4+9sxdajE798wV1H2+PYp/rRcl/soSHIBKMPw==", "license": "MIT", "dependencies": { "@apollo/cache-control-types": "^1.0.3", @@ -651,51 +651,51 @@ } }, "node_modules/@aws-sdk/client-cloudformation": { - "version": "3.1008.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-cloudformation/-/client-cloudformation-3.1008.0.tgz", - "integrity": "sha512-MLt0pBOesb2w39aVmnlZGOz6yX/bNv/MsdpIg4PXmVGR/LIUqsiRD8I4SvNOJEwqXeRnbA/EUqLUcBc+Gi3iqg==", + "version": "3.1021.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-cloudformation/-/client-cloudformation-3.1021.0.tgz", + "integrity": "sha512-SZXd1gLjyRLHf1FfID4gmGaDe/2lgwCl8LzBGeH21ol1mCL344PJK4aetQNmY51yfoInI9onVIGDf+8q/J/lpA==", "license": "Apache-2.0", "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.19", - "@aws-sdk/credential-provider-node": "^3.972.20", - "@aws-sdk/middleware-host-header": "^3.972.7", - "@aws-sdk/middleware-logger": "^3.972.7", - "@aws-sdk/middleware-recursion-detection": "^3.972.7", - "@aws-sdk/middleware-user-agent": "^3.972.20", - "@aws-sdk/region-config-resolver": "^3.972.7", - "@aws-sdk/types": "^3.973.5", - "@aws-sdk/util-endpoints": "^3.996.4", - "@aws-sdk/util-user-agent-browser": "^3.972.7", - "@aws-sdk/util-user-agent-node": "^3.973.6", - "@smithy/config-resolver": "^4.4.10", - "@smithy/core": "^3.23.9", - "@smithy/fetch-http-handler": "^5.3.13", - "@smithy/hash-node": "^4.2.11", - "@smithy/invalid-dependency": "^4.2.11", - "@smithy/middleware-content-length": "^4.2.11", - "@smithy/middleware-endpoint": "^4.4.23", - "@smithy/middleware-retry": "^4.4.40", - "@smithy/middleware-serde": "^4.2.12", - "@smithy/middleware-stack": "^4.2.11", - "@smithy/node-config-provider": "^4.3.11", - "@smithy/node-http-handler": "^4.4.14", - "@smithy/protocol-http": "^5.3.11", - "@smithy/smithy-client": "^4.12.3", - "@smithy/types": "^4.13.0", - "@smithy/url-parser": "^4.2.11", + "@aws-sdk/core": "^3.973.26", + "@aws-sdk/credential-provider-node": "^3.972.29", + "@aws-sdk/middleware-host-header": "^3.972.8", + "@aws-sdk/middleware-logger": "^3.972.8", + "@aws-sdk/middleware-recursion-detection": "^3.972.9", + "@aws-sdk/middleware-user-agent": "^3.972.28", + "@aws-sdk/region-config-resolver": "^3.972.10", + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-endpoints": "^3.996.5", + "@aws-sdk/util-user-agent-browser": "^3.972.8", + "@aws-sdk/util-user-agent-node": "^3.973.14", + "@smithy/config-resolver": "^4.4.13", + "@smithy/core": "^3.23.13", + "@smithy/fetch-http-handler": "^5.3.15", + "@smithy/hash-node": "^4.2.12", + "@smithy/invalid-dependency": "^4.2.12", + "@smithy/middleware-content-length": "^4.2.12", + "@smithy/middleware-endpoint": "^4.4.28", + "@smithy/middleware-retry": "^4.4.46", + "@smithy/middleware-serde": "^4.2.16", + "@smithy/middleware-stack": "^4.2.12", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/node-http-handler": "^4.5.1", + "@smithy/protocol-http": "^5.3.12", + "@smithy/smithy-client": "^4.12.8", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", - "@smithy/util-defaults-mode-browser": "^4.3.39", - "@smithy/util-defaults-mode-node": "^4.2.42", - "@smithy/util-endpoints": "^3.3.2", - "@smithy/util-middleware": "^4.2.11", - "@smithy/util-retry": "^4.2.11", + "@smithy/util-defaults-mode-browser": "^4.3.44", + "@smithy/util-defaults-mode-node": "^4.2.48", + "@smithy/util-endpoints": "^3.3.3", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-retry": "^4.2.13", "@smithy/util-utf8": "^4.2.2", - "@smithy/util-waiter": "^4.2.12", + "@smithy/util-waiter": "^4.2.14", "tslib": "^2.6.2" }, "engines": { @@ -703,48 +703,48 @@ } }, "node_modules/@aws-sdk/client-cognito-identity": { - "version": "3.1008.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-cognito-identity/-/client-cognito-identity-3.1008.0.tgz", - "integrity": "sha512-zzHnrTImR1JJ/Sq90y35UiFiriwge6W8qZQxIBJCgAMwEGkQAqHEAc3d6ptLmwdntcid3dx7wvauOXbpiMVbAQ==", + "version": "3.1021.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-cognito-identity/-/client-cognito-identity-3.1021.0.tgz", + "integrity": "sha512-J3sT35ekSK7xdm7yhmc4XrMIuSZgd+kIEzSRVAHkmeS3JgOl0jPGc+p0mjXy5V8jR7COb46uvsvKBTImk31QOA==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.19", - "@aws-sdk/credential-provider-node": "^3.972.20", - "@aws-sdk/middleware-host-header": "^3.972.7", - "@aws-sdk/middleware-logger": "^3.972.7", - "@aws-sdk/middleware-recursion-detection": "^3.972.7", - "@aws-sdk/middleware-user-agent": "^3.972.20", - "@aws-sdk/region-config-resolver": "^3.972.7", - "@aws-sdk/types": "^3.973.5", - "@aws-sdk/util-endpoints": "^3.996.4", - "@aws-sdk/util-user-agent-browser": "^3.972.7", - "@aws-sdk/util-user-agent-node": "^3.973.6", - "@smithy/config-resolver": "^4.4.10", - "@smithy/core": "^3.23.9", - "@smithy/fetch-http-handler": "^5.3.13", - "@smithy/hash-node": "^4.2.11", - "@smithy/invalid-dependency": "^4.2.11", - "@smithy/middleware-content-length": "^4.2.11", - "@smithy/middleware-endpoint": "^4.4.23", - "@smithy/middleware-retry": "^4.4.40", - "@smithy/middleware-serde": "^4.2.12", - "@smithy/middleware-stack": "^4.2.11", - "@smithy/node-config-provider": "^4.3.11", - "@smithy/node-http-handler": "^4.4.14", - "@smithy/protocol-http": "^5.3.11", - "@smithy/smithy-client": "^4.12.3", - "@smithy/types": "^4.13.0", - "@smithy/url-parser": "^4.2.11", + "@aws-sdk/core": "^3.973.26", + "@aws-sdk/credential-provider-node": "^3.972.29", + "@aws-sdk/middleware-host-header": "^3.972.8", + "@aws-sdk/middleware-logger": "^3.972.8", + "@aws-sdk/middleware-recursion-detection": "^3.972.9", + "@aws-sdk/middleware-user-agent": "^3.972.28", + "@aws-sdk/region-config-resolver": "^3.972.10", + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-endpoints": "^3.996.5", + "@aws-sdk/util-user-agent-browser": "^3.972.8", + "@aws-sdk/util-user-agent-node": "^3.973.14", + "@smithy/config-resolver": "^4.4.13", + "@smithy/core": "^3.23.13", + "@smithy/fetch-http-handler": "^5.3.15", + "@smithy/hash-node": "^4.2.12", + "@smithy/invalid-dependency": "^4.2.12", + "@smithy/middleware-content-length": "^4.2.12", + "@smithy/middleware-endpoint": "^4.4.28", + "@smithy/middleware-retry": "^4.4.46", + "@smithy/middleware-serde": "^4.2.16", + "@smithy/middleware-stack": "^4.2.12", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/node-http-handler": "^4.5.1", + "@smithy/protocol-http": "^5.3.12", + "@smithy/smithy-client": "^4.12.8", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", - "@smithy/util-defaults-mode-browser": "^4.3.39", - "@smithy/util-defaults-mode-node": "^4.2.42", - "@smithy/util-endpoints": "^3.3.2", - "@smithy/util-middleware": "^4.2.11", - "@smithy/util-retry": "^4.2.11", + "@smithy/util-defaults-mode-browser": "^4.3.44", + "@smithy/util-defaults-mode-node": "^4.2.48", + "@smithy/util-endpoints": "^3.3.3", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-retry": "^4.2.13", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -753,52 +753,52 @@ } }, "node_modules/@aws-sdk/client-dynamodb": { - "version": "3.1008.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-dynamodb/-/client-dynamodb-3.1008.0.tgz", - "integrity": "sha512-R3jj83tMilipIPKj+Qd0vKsYhcgrMqG8YoEhvW4RSLImIg0nrcZSO1ZO4xz3NT7SovSGloZiv1m15w4m7j7VvQ==", + "version": "3.1021.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-dynamodb/-/client-dynamodb-3.1021.0.tgz", + "integrity": "sha512-Yp7p5HZh4ZAOqV7kbOP8ClPGJxFUTm+FRYLQAsA3x22rB3Q+rgcr+avBNxjS2AX7NeRCI6LY4zoeVYvILWEq3Q==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.19", - "@aws-sdk/credential-provider-node": "^3.972.20", - "@aws-sdk/dynamodb-codec": "^3.972.20", - "@aws-sdk/middleware-endpoint-discovery": "^3.972.7", - "@aws-sdk/middleware-host-header": "^3.972.7", - "@aws-sdk/middleware-logger": "^3.972.7", - "@aws-sdk/middleware-recursion-detection": "^3.972.7", - "@aws-sdk/middleware-user-agent": "^3.972.20", - "@aws-sdk/region-config-resolver": "^3.972.7", - "@aws-sdk/types": "^3.973.5", - "@aws-sdk/util-endpoints": "^3.996.4", - "@aws-sdk/util-user-agent-browser": "^3.972.7", - "@aws-sdk/util-user-agent-node": "^3.973.6", - "@smithy/config-resolver": "^4.4.10", - "@smithy/core": "^3.23.9", - "@smithy/fetch-http-handler": "^5.3.13", - "@smithy/hash-node": "^4.2.11", - "@smithy/invalid-dependency": "^4.2.11", - "@smithy/middleware-content-length": "^4.2.11", - "@smithy/middleware-endpoint": "^4.4.23", - "@smithy/middleware-retry": "^4.4.40", - "@smithy/middleware-serde": "^4.2.12", - "@smithy/middleware-stack": "^4.2.11", - "@smithy/node-config-provider": "^4.3.11", - "@smithy/node-http-handler": "^4.4.14", - "@smithy/protocol-http": "^5.3.11", - "@smithy/smithy-client": "^4.12.3", - "@smithy/types": "^4.13.0", - "@smithy/url-parser": "^4.2.11", + "@aws-sdk/core": "^3.973.26", + "@aws-sdk/credential-provider-node": "^3.972.29", + "@aws-sdk/dynamodb-codec": "^3.972.27", + "@aws-sdk/middleware-endpoint-discovery": "^3.972.9", + "@aws-sdk/middleware-host-header": "^3.972.8", + "@aws-sdk/middleware-logger": "^3.972.8", + "@aws-sdk/middleware-recursion-detection": "^3.972.9", + "@aws-sdk/middleware-user-agent": "^3.972.28", + "@aws-sdk/region-config-resolver": "^3.972.10", + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-endpoints": "^3.996.5", + "@aws-sdk/util-user-agent-browser": "^3.972.8", + "@aws-sdk/util-user-agent-node": "^3.973.14", + "@smithy/config-resolver": "^4.4.13", + "@smithy/core": "^3.23.13", + "@smithy/fetch-http-handler": "^5.3.15", + "@smithy/hash-node": "^4.2.12", + "@smithy/invalid-dependency": "^4.2.12", + "@smithy/middleware-content-length": "^4.2.12", + "@smithy/middleware-endpoint": "^4.4.28", + "@smithy/middleware-retry": "^4.4.46", + "@smithy/middleware-serde": "^4.2.16", + "@smithy/middleware-stack": "^4.2.12", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/node-http-handler": "^4.5.1", + "@smithy/protocol-http": "^5.3.12", + "@smithy/smithy-client": "^4.12.8", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", - "@smithy/util-defaults-mode-browser": "^4.3.39", - "@smithy/util-defaults-mode-node": "^4.2.42", - "@smithy/util-endpoints": "^3.3.2", - "@smithy/util-middleware": "^4.2.11", - "@smithy/util-retry": "^4.2.11", + "@smithy/util-defaults-mode-browser": "^4.3.44", + "@smithy/util-defaults-mode-node": "^4.2.48", + "@smithy/util-endpoints": "^3.3.3", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-retry": "^4.2.13", "@smithy/util-utf8": "^4.2.2", - "@smithy/util-waiter": "^4.2.12", + "@smithy/util-waiter": "^4.2.14", "tslib": "^2.6.2" }, "engines": { @@ -806,66 +806,66 @@ } }, "node_modules/@aws-sdk/client-s3": { - "version": "3.1008.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1008.0.tgz", - "integrity": "sha512-w/SIRD25v2zVMbkn8CYIxUsac8yf5Jghkhw5j7EsNWdJhl56m/nWpUX7t1etFUW1cnzpFjZV0lXt0dNFSnbXwA==", + "version": "3.1021.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1021.0.tgz", + "integrity": "sha512-BCfggq8gYSjlKOZlMSVApix3cgKAQIWGeoJFX/AU5HMvqz1BZBEw83jJFL9LYrqTPCocH8NGl++1Xr70ro+jcg==", "license": "Apache-2.0", "peer": true, "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.19", - "@aws-sdk/credential-provider-node": "^3.972.20", - "@aws-sdk/middleware-bucket-endpoint": "^3.972.7", - "@aws-sdk/middleware-expect-continue": "^3.972.7", - "@aws-sdk/middleware-flexible-checksums": "^3.973.5", - "@aws-sdk/middleware-host-header": "^3.972.7", - "@aws-sdk/middleware-location-constraint": "^3.972.7", - "@aws-sdk/middleware-logger": "^3.972.7", - "@aws-sdk/middleware-recursion-detection": "^3.972.7", - "@aws-sdk/middleware-sdk-s3": "^3.972.19", - "@aws-sdk/middleware-ssec": "^3.972.7", - "@aws-sdk/middleware-user-agent": "^3.972.20", - "@aws-sdk/region-config-resolver": "^3.972.7", - "@aws-sdk/signature-v4-multi-region": "^3.996.7", - "@aws-sdk/types": "^3.973.5", - "@aws-sdk/util-endpoints": "^3.996.4", - "@aws-sdk/util-user-agent-browser": "^3.972.7", - "@aws-sdk/util-user-agent-node": "^3.973.6", - "@smithy/config-resolver": "^4.4.10", - "@smithy/core": "^3.23.9", - "@smithy/eventstream-serde-browser": "^4.2.11", - "@smithy/eventstream-serde-config-resolver": "^4.3.11", - "@smithy/eventstream-serde-node": "^4.2.11", - "@smithy/fetch-http-handler": "^5.3.13", - "@smithy/hash-blob-browser": "^4.2.12", - "@smithy/hash-node": "^4.2.11", - "@smithy/hash-stream-node": "^4.2.11", - "@smithy/invalid-dependency": "^4.2.11", - "@smithy/md5-js": "^4.2.11", - "@smithy/middleware-content-length": "^4.2.11", - "@smithy/middleware-endpoint": "^4.4.23", - "@smithy/middleware-retry": "^4.4.40", - "@smithy/middleware-serde": "^4.2.12", - "@smithy/middleware-stack": "^4.2.11", - "@smithy/node-config-provider": "^4.3.11", - "@smithy/node-http-handler": "^4.4.14", - "@smithy/protocol-http": "^5.3.11", - "@smithy/smithy-client": "^4.12.3", - "@smithy/types": "^4.13.0", - "@smithy/url-parser": "^4.2.11", + "@aws-sdk/core": "^3.973.26", + "@aws-sdk/credential-provider-node": "^3.972.29", + "@aws-sdk/middleware-bucket-endpoint": "^3.972.8", + "@aws-sdk/middleware-expect-continue": "^3.972.8", + "@aws-sdk/middleware-flexible-checksums": "^3.974.6", + "@aws-sdk/middleware-host-header": "^3.972.8", + "@aws-sdk/middleware-location-constraint": "^3.972.8", + "@aws-sdk/middleware-logger": "^3.972.8", + "@aws-sdk/middleware-recursion-detection": "^3.972.9", + "@aws-sdk/middleware-sdk-s3": "^3.972.27", + "@aws-sdk/middleware-ssec": "^3.972.8", + "@aws-sdk/middleware-user-agent": "^3.972.28", + "@aws-sdk/region-config-resolver": "^3.972.10", + "@aws-sdk/signature-v4-multi-region": "^3.996.15", + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-endpoints": "^3.996.5", + "@aws-sdk/util-user-agent-browser": "^3.972.8", + "@aws-sdk/util-user-agent-node": "^3.973.14", + "@smithy/config-resolver": "^4.4.13", + "@smithy/core": "^3.23.13", + "@smithy/eventstream-serde-browser": "^4.2.12", + "@smithy/eventstream-serde-config-resolver": "^4.3.12", + "@smithy/eventstream-serde-node": "^4.2.12", + "@smithy/fetch-http-handler": "^5.3.15", + "@smithy/hash-blob-browser": "^4.2.13", + "@smithy/hash-node": "^4.2.12", + "@smithy/hash-stream-node": "^4.2.12", + "@smithy/invalid-dependency": "^4.2.12", + "@smithy/md5-js": "^4.2.12", + "@smithy/middleware-content-length": "^4.2.12", + "@smithy/middleware-endpoint": "^4.4.28", + "@smithy/middleware-retry": "^4.4.46", + "@smithy/middleware-serde": "^4.2.16", + "@smithy/middleware-stack": "^4.2.12", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/node-http-handler": "^4.5.1", + "@smithy/protocol-http": "^5.3.12", + "@smithy/smithy-client": "^4.12.8", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", - "@smithy/util-defaults-mode-browser": "^4.3.39", - "@smithy/util-defaults-mode-node": "^4.2.42", - "@smithy/util-endpoints": "^3.3.2", - "@smithy/util-middleware": "^4.2.11", - "@smithy/util-retry": "^4.2.11", - "@smithy/util-stream": "^4.5.17", + "@smithy/util-defaults-mode-browser": "^4.3.44", + "@smithy/util-defaults-mode-node": "^4.2.48", + "@smithy/util-endpoints": "^3.3.3", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-retry": "^4.2.13", + "@smithy/util-stream": "^4.5.21", "@smithy/util-utf8": "^4.2.2", - "@smithy/util-waiter": "^4.2.12", + "@smithy/util-waiter": "^4.2.14", "tslib": "^2.6.2" }, "engines": { @@ -873,49 +873,49 @@ } }, "node_modules/@aws-sdk/client-sns": { - "version": "3.1008.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sns/-/client-sns-3.1008.0.tgz", - "integrity": "sha512-0kS21nO2o+Wjvvh1to0aHCKRxQEMqOd30nNwg7o9CLjEs3oB5tNw/Rpkmw2vPPZtYSbke6OvQDy9Pdj6UWU8jg==", + "version": "3.1021.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sns/-/client-sns-3.1021.0.tgz", + "integrity": "sha512-rAwHx1iGNzfFTlz/VdYu6BFsiYIujk48yaafqQED91DWgM03f+9TDfFBXxnlIm5LBNwkXc/UWFmxOV29yP3mgg==", "license": "Apache-2.0", "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.19", - "@aws-sdk/credential-provider-node": "^3.972.20", - "@aws-sdk/middleware-host-header": "^3.972.7", - "@aws-sdk/middleware-logger": "^3.972.7", - "@aws-sdk/middleware-recursion-detection": "^3.972.7", - "@aws-sdk/middleware-user-agent": "^3.972.20", - "@aws-sdk/region-config-resolver": "^3.972.7", - "@aws-sdk/types": "^3.973.5", - "@aws-sdk/util-endpoints": "^3.996.4", - "@aws-sdk/util-user-agent-browser": "^3.972.7", - "@aws-sdk/util-user-agent-node": "^3.973.6", - "@smithy/config-resolver": "^4.4.10", - "@smithy/core": "^3.23.9", - "@smithy/fetch-http-handler": "^5.3.13", - "@smithy/hash-node": "^4.2.11", - "@smithy/invalid-dependency": "^4.2.11", - "@smithy/middleware-content-length": "^4.2.11", - "@smithy/middleware-endpoint": "^4.4.23", - "@smithy/middleware-retry": "^4.4.40", - "@smithy/middleware-serde": "^4.2.12", - "@smithy/middleware-stack": "^4.2.11", - "@smithy/node-config-provider": "^4.3.11", - "@smithy/node-http-handler": "^4.4.14", - "@smithy/protocol-http": "^5.3.11", - "@smithy/smithy-client": "^4.12.3", - "@smithy/types": "^4.13.0", - "@smithy/url-parser": "^4.2.11", + "@aws-sdk/core": "^3.973.26", + "@aws-sdk/credential-provider-node": "^3.972.29", + "@aws-sdk/middleware-host-header": "^3.972.8", + "@aws-sdk/middleware-logger": "^3.972.8", + "@aws-sdk/middleware-recursion-detection": "^3.972.9", + "@aws-sdk/middleware-user-agent": "^3.972.28", + "@aws-sdk/region-config-resolver": "^3.972.10", + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-endpoints": "^3.996.5", + "@aws-sdk/util-user-agent-browser": "^3.972.8", + "@aws-sdk/util-user-agent-node": "^3.973.14", + "@smithy/config-resolver": "^4.4.13", + "@smithy/core": "^3.23.13", + "@smithy/fetch-http-handler": "^5.3.15", + "@smithy/hash-node": "^4.2.12", + "@smithy/invalid-dependency": "^4.2.12", + "@smithy/middleware-content-length": "^4.2.12", + "@smithy/middleware-endpoint": "^4.4.28", + "@smithy/middleware-retry": "^4.4.46", + "@smithy/middleware-serde": "^4.2.16", + "@smithy/middleware-stack": "^4.2.12", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/node-http-handler": "^4.5.1", + "@smithy/protocol-http": "^5.3.12", + "@smithy/smithy-client": "^4.12.8", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", - "@smithy/util-defaults-mode-browser": "^4.3.39", - "@smithy/util-defaults-mode-node": "^4.2.42", - "@smithy/util-endpoints": "^3.3.2", - "@smithy/util-middleware": "^4.2.11", - "@smithy/util-retry": "^4.2.11", + "@smithy/util-defaults-mode-browser": "^4.3.44", + "@smithy/util-defaults-mode-node": "^4.2.48", + "@smithy/util-endpoints": "^3.3.3", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-retry": "^4.2.13", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -924,51 +924,51 @@ } }, "node_modules/@aws-sdk/client-sqs": { - "version": "3.1008.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sqs/-/client-sqs-3.1008.0.tgz", - "integrity": "sha512-kBqU6zt4Nw3Oc0ArpQakayTS0N/mbWQY8TUkPMSFTqdEpmCOqou9NjoFvpLEz5JnUraxG9BNRzIvQfV5mVkeqQ==", + "version": "3.1021.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sqs/-/client-sqs-3.1021.0.tgz", + "integrity": "sha512-e75zH3qvA0rHDSL92deHCuCQeABmMIcfwkWeGBb9KukeL7H5NIzdNrHUq1ZJO9Idn9Gi+/YTZKHTPYDg460wXA==", "license": "Apache-2.0", "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.19", - "@aws-sdk/credential-provider-node": "^3.972.20", - "@aws-sdk/middleware-host-header": "^3.972.7", - "@aws-sdk/middleware-logger": "^3.972.7", - "@aws-sdk/middleware-recursion-detection": "^3.972.7", - "@aws-sdk/middleware-sdk-sqs": "^3.972.14", - "@aws-sdk/middleware-user-agent": "^3.972.20", - "@aws-sdk/region-config-resolver": "^3.972.7", - "@aws-sdk/types": "^3.973.5", - "@aws-sdk/util-endpoints": "^3.996.4", - "@aws-sdk/util-user-agent-browser": "^3.972.7", - "@aws-sdk/util-user-agent-node": "^3.973.6", - "@smithy/config-resolver": "^4.4.10", - "@smithy/core": "^3.23.9", - "@smithy/fetch-http-handler": "^5.3.13", - "@smithy/hash-node": "^4.2.11", - "@smithy/invalid-dependency": "^4.2.11", - "@smithy/md5-js": "^4.2.11", - "@smithy/middleware-content-length": "^4.2.11", - "@smithy/middleware-endpoint": "^4.4.23", - "@smithy/middleware-retry": "^4.4.40", - "@smithy/middleware-serde": "^4.2.12", - "@smithy/middleware-stack": "^4.2.11", - "@smithy/node-config-provider": "^4.3.11", - "@smithy/node-http-handler": "^4.4.14", - "@smithy/protocol-http": "^5.3.11", - "@smithy/smithy-client": "^4.12.3", - "@smithy/types": "^4.13.0", - "@smithy/url-parser": "^4.2.11", + "@aws-sdk/core": "^3.973.26", + "@aws-sdk/credential-provider-node": "^3.972.29", + "@aws-sdk/middleware-host-header": "^3.972.8", + "@aws-sdk/middleware-logger": "^3.972.8", + "@aws-sdk/middleware-recursion-detection": "^3.972.9", + "@aws-sdk/middleware-sdk-sqs": "^3.972.18", + "@aws-sdk/middleware-user-agent": "^3.972.28", + "@aws-sdk/region-config-resolver": "^3.972.10", + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-endpoints": "^3.996.5", + "@aws-sdk/util-user-agent-browser": "^3.972.8", + "@aws-sdk/util-user-agent-node": "^3.973.14", + "@smithy/config-resolver": "^4.4.13", + "@smithy/core": "^3.23.13", + "@smithy/fetch-http-handler": "^5.3.15", + "@smithy/hash-node": "^4.2.12", + "@smithy/invalid-dependency": "^4.2.12", + "@smithy/md5-js": "^4.2.12", + "@smithy/middleware-content-length": "^4.2.12", + "@smithy/middleware-endpoint": "^4.4.28", + "@smithy/middleware-retry": "^4.4.46", + "@smithy/middleware-serde": "^4.2.16", + "@smithy/middleware-stack": "^4.2.12", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/node-http-handler": "^4.5.1", + "@smithy/protocol-http": "^5.3.12", + "@smithy/smithy-client": "^4.12.8", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", - "@smithy/util-defaults-mode-browser": "^4.3.39", - "@smithy/util-defaults-mode-node": "^4.2.42", - "@smithy/util-endpoints": "^3.3.2", - "@smithy/util-middleware": "^4.2.11", - "@smithy/util-retry": "^4.2.11", + "@smithy/util-defaults-mode-browser": "^4.3.44", + "@smithy/util-defaults-mode-node": "^4.2.48", + "@smithy/util-endpoints": "^3.3.3", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-retry": "^4.2.13", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -977,50 +977,50 @@ } }, "node_modules/@aws-sdk/client-ssm": { - "version": "3.1008.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-ssm/-/client-ssm-3.1008.0.tgz", - "integrity": "sha512-8PpuP4JgE3Sdv6/TNjM2Qqu7Ai0e2CzjESb+PZfZ4fc3M222sR098/+wm5qMKULgi4LjAo6hqpjlCMOSCSfnJA==", + "version": "3.1021.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-ssm/-/client-ssm-3.1021.0.tgz", + "integrity": "sha512-WiWmvDT6R95FRzM6WGAxDtyESI8XID/F+T3UFjYpYgUrqF6/MsrmxIeMaFBo/rDoGkZGdTm3G18mgmms4sh7Pw==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.19", - "@aws-sdk/credential-provider-node": "^3.972.20", - "@aws-sdk/middleware-host-header": "^3.972.7", - "@aws-sdk/middleware-logger": "^3.972.7", - "@aws-sdk/middleware-recursion-detection": "^3.972.7", - "@aws-sdk/middleware-user-agent": "^3.972.20", - "@aws-sdk/region-config-resolver": "^3.972.7", - "@aws-sdk/types": "^3.973.5", - "@aws-sdk/util-endpoints": "^3.996.4", - "@aws-sdk/util-user-agent-browser": "^3.972.7", - "@aws-sdk/util-user-agent-node": "^3.973.6", - "@smithy/config-resolver": "^4.4.10", - "@smithy/core": "^3.23.9", - "@smithy/fetch-http-handler": "^5.3.13", - "@smithy/hash-node": "^4.2.11", - "@smithy/invalid-dependency": "^4.2.11", - "@smithy/middleware-content-length": "^4.2.11", - "@smithy/middleware-endpoint": "^4.4.23", - "@smithy/middleware-retry": "^4.4.40", - "@smithy/middleware-serde": "^4.2.12", - "@smithy/middleware-stack": "^4.2.11", - "@smithy/node-config-provider": "^4.3.11", - "@smithy/node-http-handler": "^4.4.14", - "@smithy/protocol-http": "^5.3.11", - "@smithy/smithy-client": "^4.12.3", - "@smithy/types": "^4.13.0", - "@smithy/url-parser": "^4.2.11", + "@aws-sdk/core": "^3.973.26", + "@aws-sdk/credential-provider-node": "^3.972.29", + "@aws-sdk/middleware-host-header": "^3.972.8", + "@aws-sdk/middleware-logger": "^3.972.8", + "@aws-sdk/middleware-recursion-detection": "^3.972.9", + "@aws-sdk/middleware-user-agent": "^3.972.28", + "@aws-sdk/region-config-resolver": "^3.972.10", + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-endpoints": "^3.996.5", + "@aws-sdk/util-user-agent-browser": "^3.972.8", + "@aws-sdk/util-user-agent-node": "^3.973.14", + "@smithy/config-resolver": "^4.4.13", + "@smithy/core": "^3.23.13", + "@smithy/fetch-http-handler": "^5.3.15", + "@smithy/hash-node": "^4.2.12", + "@smithy/invalid-dependency": "^4.2.12", + "@smithy/middleware-content-length": "^4.2.12", + "@smithy/middleware-endpoint": "^4.4.28", + "@smithy/middleware-retry": "^4.4.46", + "@smithy/middleware-serde": "^4.2.16", + "@smithy/middleware-stack": "^4.2.12", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/node-http-handler": "^4.5.1", + "@smithy/protocol-http": "^5.3.12", + "@smithy/smithy-client": "^4.12.8", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", - "@smithy/util-defaults-mode-browser": "^4.3.39", - "@smithy/util-defaults-mode-node": "^4.2.42", - "@smithy/util-endpoints": "^3.3.2", - "@smithy/util-middleware": "^4.2.11", - "@smithy/util-retry": "^4.2.11", + "@smithy/util-defaults-mode-browser": "^4.3.44", + "@smithy/util-defaults-mode-node": "^4.2.48", + "@smithy/util-endpoints": "^3.3.3", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-retry": "^4.2.13", "@smithy/util-utf8": "^4.2.2", - "@smithy/util-waiter": "^4.2.12", + "@smithy/util-waiter": "^4.2.14", "tslib": "^2.6.2" }, "engines": { @@ -1028,22 +1028,22 @@ } }, "node_modules/@aws-sdk/core": { - "version": "3.973.19", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.19.tgz", - "integrity": "sha512-56KePyOcZnKTWCd89oJS1G6j3HZ9Kc+bh/8+EbvtaCCXdP6T7O7NzCiPuHRhFLWnzXIaXX3CxAz0nI5My9spHQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.5", - "@aws-sdk/xml-builder": "^3.972.10", - "@smithy/core": "^3.23.9", - "@smithy/node-config-provider": "^4.3.11", - "@smithy/property-provider": "^4.2.11", - "@smithy/protocol-http": "^5.3.11", - "@smithy/signature-v4": "^5.3.11", - "@smithy/smithy-client": "^4.12.3", - "@smithy/types": "^4.13.0", + "version": "3.973.26", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.26.tgz", + "integrity": "sha512-A/E6n2W42ruU+sfWk+mMUOyVXbsSgGrY3MJ9/0Az5qUdG67y8I6HYzzoAa+e/lzxxl1uCYmEL6BTMi9ZiZnplQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/xml-builder": "^3.972.16", + "@smithy/core": "^3.23.13", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/property-provider": "^4.2.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/signature-v4": "^5.3.12", + "@smithy/smithy-client": "^4.12.8", + "@smithy/types": "^4.13.1", "@smithy/util-base64": "^4.3.2", - "@smithy/util-middleware": "^4.2.11", + "@smithy/util-middleware": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -1052,13 +1052,13 @@ } }, "node_modules/@aws-sdk/crc64-nvme": { - "version": "3.972.4", - "resolved": "https://registry.npmjs.org/@aws-sdk/crc64-nvme/-/crc64-nvme-3.972.4.tgz", - "integrity": "sha512-HKZIZLbRyvzo/bXZU7Zmk6XqU+1C9DjI56xd02vwuDIxedxBEqP17t9ExhbP9QFeNq/a3l9GOcyirFXxmbDhmw==", + "version": "3.972.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/crc64-nvme/-/crc64-nvme-3.972.5.tgz", + "integrity": "sha512-2VbTstbjKdT+yKi8m7b3a9CiVac+pL/IY2PHJwsaGkkHmuuqkJZIErPck1h6P3T9ghQMLSdMPyW6Qp7Di5swFg==", "license": "Apache-2.0", "peer": true, "dependencies": { - "@smithy/types": "^4.13.0", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -1066,15 +1066,15 @@ } }, "node_modules/@aws-sdk/credential-provider-cognito-identity": { - "version": "3.972.12", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-cognito-identity/-/credential-provider-cognito-identity-3.972.12.tgz", - "integrity": "sha512-0R7EKJBd19VGoYMrp7ozikwRh6KpapIO3T/Vf9tMrAVxrUNd5V+A6V1gxypY7iJv9GwVR1ZWL/nFt/m0KvcjIQ==", + "version": "3.972.21", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-cognito-identity/-/credential-provider-cognito-identity-3.972.21.tgz", + "integrity": "sha512-3ooy5gLnMLgWtkxz53P9R0RiSSCCHn576kyfy/L88QXOqS/G4wYTsqoNJBGZ0Kg46FlQ9jZHuZThbyeEeXgW/g==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/nested-clients": "^3.996.9", - "@aws-sdk/types": "^3.973.5", - "@smithy/property-provider": "^4.2.11", - "@smithy/types": "^4.13.0", + "@aws-sdk/nested-clients": "^3.996.18", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -1082,15 +1082,15 @@ } }, "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.17", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.17.tgz", - "integrity": "sha512-MBAMW6YELzE1SdkOniqr51mrjapQUv8JXSGxtwRjQV0mwVDutVsn22OPAUt4RcLRvdiHQmNBDEFP9iTeSVCOlA==", + "version": "3.972.24", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.24.tgz", + "integrity": "sha512-FWg8uFmT6vQM7VuzELzwVo5bzExGaKHdubn0StjgrcU5FvuLExUe+k06kn/40uKv59rYzhez8eFNM4yYE/Yb/w==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.19", - "@aws-sdk/types": "^3.973.5", - "@smithy/property-provider": "^4.2.11", - "@smithy/types": "^4.13.0", + "@aws-sdk/core": "^3.973.26", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -1098,20 +1098,20 @@ } }, "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.19", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.19.tgz", - "integrity": "sha512-9EJROO8LXll5a7eUFqu48k6BChrtokbmgeMWmsH7lBb6lVbtjslUYz/ShLi+SHkYzTomiGBhmzTW7y+H4BxsnA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.973.19", - "@aws-sdk/types": "^3.973.5", - "@smithy/fetch-http-handler": "^5.3.13", - "@smithy/node-http-handler": "^4.4.14", - "@smithy/property-provider": "^4.2.11", - "@smithy/protocol-http": "^5.3.11", - "@smithy/smithy-client": "^4.12.3", - "@smithy/types": "^4.13.0", - "@smithy/util-stream": "^4.5.17", + "version": "3.972.26", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.26.tgz", + "integrity": "sha512-CY4ppZ+qHYqcXqBVi//sdHST1QK3KzOEiLtpLsc9W2k2vfZPKExGaQIsOwcyvjpjUEolotitmd3mUNY56IwDEA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.26", + "@aws-sdk/types": "^3.973.6", + "@smithy/fetch-http-handler": "^5.3.15", + "@smithy/node-http-handler": "^4.5.1", + "@smithy/property-provider": "^4.2.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/smithy-client": "^4.12.8", + "@smithy/types": "^4.13.1", + "@smithy/util-stream": "^4.5.21", "tslib": "^2.6.2" }, "engines": { @@ -1119,24 +1119,24 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.972.19", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.19.tgz", - "integrity": "sha512-pVJVjWqVrPqjpFq7o0mCmeZu1Y0c94OCHSYgivdCD2wfmYVtBbwQErakruhgOD8pcMcx9SCqRw1pzHKR7OGBcA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.973.19", - "@aws-sdk/credential-provider-env": "^3.972.17", - "@aws-sdk/credential-provider-http": "^3.972.19", - "@aws-sdk/credential-provider-login": "^3.972.19", - "@aws-sdk/credential-provider-process": "^3.972.17", - "@aws-sdk/credential-provider-sso": "^3.972.19", - "@aws-sdk/credential-provider-web-identity": "^3.972.19", - "@aws-sdk/nested-clients": "^3.996.9", - "@aws-sdk/types": "^3.973.5", - "@smithy/credential-provider-imds": "^4.2.11", - "@smithy/property-provider": "^4.2.11", - "@smithy/shared-ini-file-loader": "^4.4.6", - "@smithy/types": "^4.13.0", + "version": "3.972.28", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.28.tgz", + "integrity": "sha512-wXYvq3+uQcZV7k+bE4yDXCTBdzWTU9x/nMiKBfzInmv6yYK1veMK0AKvRfRBd72nGWYKcL6AxwiPg9z/pYlgpw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.26", + "@aws-sdk/credential-provider-env": "^3.972.24", + "@aws-sdk/credential-provider-http": "^3.972.26", + "@aws-sdk/credential-provider-login": "^3.972.28", + "@aws-sdk/credential-provider-process": "^3.972.24", + "@aws-sdk/credential-provider-sso": "^3.972.28", + "@aws-sdk/credential-provider-web-identity": "^3.972.28", + "@aws-sdk/nested-clients": "^3.996.18", + "@aws-sdk/types": "^3.973.6", + "@smithy/credential-provider-imds": "^4.2.12", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -1144,18 +1144,18 @@ } }, "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.19", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.19.tgz", - "integrity": "sha512-jOXdZ1o+CywQKr6gyxgxuUmnGwTTnY2Kxs1PM7fI6AYtDWDnmW/yKXayNqkF8KjP1unflqMWKVbVt5VgmE3L0g==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.973.19", - "@aws-sdk/nested-clients": "^3.996.9", - "@aws-sdk/types": "^3.973.5", - "@smithy/property-provider": "^4.2.11", - "@smithy/protocol-http": "^5.3.11", - "@smithy/shared-ini-file-loader": "^4.4.6", - "@smithy/types": "^4.13.0", + "version": "3.972.28", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.28.tgz", + "integrity": "sha512-ZSTfO6jqUTCysbdBPtEX5OUR//3rbD0lN7jO3sQeS2Gjr/Y+DT6SbIJ0oT2cemNw3UzKu97sNONd1CwNMthuZQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.26", + "@aws-sdk/nested-clients": "^3.996.18", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -1163,22 +1163,22 @@ } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.20", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.20.tgz", - "integrity": "sha512-0xHca2BnPY0kzjDYPH7vk8YbfdBPpWVS67rtqQMalYDQUCBYS37cZ55K6TuFxCoIyNZgSCFrVKr9PXC5BVvQQw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.17", - "@aws-sdk/credential-provider-http": "^3.972.19", - "@aws-sdk/credential-provider-ini": "^3.972.19", - "@aws-sdk/credential-provider-process": "^3.972.17", - "@aws-sdk/credential-provider-sso": "^3.972.19", - "@aws-sdk/credential-provider-web-identity": "^3.972.19", - "@aws-sdk/types": "^3.973.5", - "@smithy/credential-provider-imds": "^4.2.11", - "@smithy/property-provider": "^4.2.11", - "@smithy/shared-ini-file-loader": "^4.4.6", - "@smithy/types": "^4.13.0", + "version": "3.972.29", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.29.tgz", + "integrity": "sha512-clSzDcvndpFJAggLDnDb36sPdlZYyEs5Zm6zgZjjUhwsJgSWiWKwFIXUVBcbruidNyBdbpOv2tNDL9sX8y3/0g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.24", + "@aws-sdk/credential-provider-http": "^3.972.26", + "@aws-sdk/credential-provider-ini": "^3.972.28", + "@aws-sdk/credential-provider-process": "^3.972.24", + "@aws-sdk/credential-provider-sso": "^3.972.28", + "@aws-sdk/credential-provider-web-identity": "^3.972.28", + "@aws-sdk/types": "^3.973.6", + "@smithy/credential-provider-imds": "^4.2.12", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -1186,16 +1186,16 @@ } }, "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.17", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.17.tgz", - "integrity": "sha512-c8G8wT1axpJDgaP3xzcy+q8Y1fTi9A2eIQJvyhQ9xuXrUZhlCfXbC0vM9bM1CUXiZppFQ1p7g0tuUMvil/gCPg==", + "version": "3.972.24", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.24.tgz", + "integrity": "sha512-Q2k/XLrFXhEztPHqj4SLCNID3hEPdlhh1CDLBpNnM+1L8fq7P+yON9/9M1IGN/dA5W45v44ylERfXtDAlmMNmw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.19", - "@aws-sdk/types": "^3.973.5", - "@smithy/property-provider": "^4.2.11", - "@smithy/shared-ini-file-loader": "^4.4.6", - "@smithy/types": "^4.13.0", + "@aws-sdk/core": "^3.973.26", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -1203,18 +1203,18 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.972.19", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.19.tgz", - "integrity": "sha512-kVjQsEU3b///q7EZGrUzol9wzwJFKbEzqJKSq82A9ShrUTEO7FNylTtby3sPV19ndADZh1H3FB3+5ZrvKtEEeg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.973.19", - "@aws-sdk/nested-clients": "^3.996.9", - "@aws-sdk/token-providers": "3.1008.0", - "@aws-sdk/types": "^3.973.5", - "@smithy/property-provider": "^4.2.11", - "@smithy/shared-ini-file-loader": "^4.4.6", - "@smithy/types": "^4.13.0", + "version": "3.972.28", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.28.tgz", + "integrity": "sha512-IoUlmKMLEITFn1SiCTjPfR6KrE799FBo5baWyk/5Ppar2yXZoUdaRqZzJzK6TcJxx450M8m8DbpddRVYlp5R/A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.26", + "@aws-sdk/nested-clients": "^3.996.18", + "@aws-sdk/token-providers": "3.1021.0", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -1222,17 +1222,17 @@ } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.19", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.19.tgz", - "integrity": "sha512-BV1BlTFdG4w4tAihxN7iXDBoNcNewXD4q8uZlNQiUrnqxwGWUhKHODIQVSPlQGxXClEj+63m+cqZskw+ESmeZg==", + "version": "3.972.28", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.28.tgz", + "integrity": "sha512-d+6h0SD8GGERzKe27v5rOzNGKOl0D+l0bWJdqrxH8WSQzHzjsQFIAPgIeOTUwBHVsKKwtSxc91K/SWax6XgswQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.19", - "@aws-sdk/nested-clients": "^3.996.9", - "@aws-sdk/types": "^3.973.5", - "@smithy/property-provider": "^4.2.11", - "@smithy/shared-ini-file-loader": "^4.4.6", - "@smithy/types": "^4.13.0", + "@aws-sdk/core": "^3.973.26", + "@aws-sdk/nested-clients": "^3.996.18", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -1240,30 +1240,30 @@ } }, "node_modules/@aws-sdk/credential-providers": { - "version": "3.1008.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-providers/-/credential-providers-3.1008.0.tgz", - "integrity": "sha512-JPjsKAYpuaDwmeE2WvrrfTb27FYa6kIe0gj1JCazHWGteQ6LDycBddsDsRSgq2MfqAqdcHnrgnfGzY1+j8AxoQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/client-cognito-identity": "3.1008.0", - "@aws-sdk/core": "^3.973.19", - "@aws-sdk/credential-provider-cognito-identity": "^3.972.12", - "@aws-sdk/credential-provider-env": "^3.972.17", - "@aws-sdk/credential-provider-http": "^3.972.19", - "@aws-sdk/credential-provider-ini": "^3.972.19", - "@aws-sdk/credential-provider-login": "^3.972.19", - "@aws-sdk/credential-provider-node": "^3.972.20", - "@aws-sdk/credential-provider-process": "^3.972.17", - "@aws-sdk/credential-provider-sso": "^3.972.19", - "@aws-sdk/credential-provider-web-identity": "^3.972.19", - "@aws-sdk/nested-clients": "^3.996.9", - "@aws-sdk/types": "^3.973.5", - "@smithy/config-resolver": "^4.4.10", - "@smithy/core": "^3.23.9", - "@smithy/credential-provider-imds": "^4.2.11", - "@smithy/node-config-provider": "^4.3.11", - "@smithy/property-provider": "^4.2.11", - "@smithy/types": "^4.13.0", + "version": "3.1021.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-providers/-/credential-providers-3.1021.0.tgz", + "integrity": "sha512-paB93zLnBGEVgKhb3dRqfY6m5iNsTprm7fPvbTxZYGElqZTlbV3Ei3mQHuNA80mHrJ18lRtN6Yzinl++u6754w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/client-cognito-identity": "3.1021.0", + "@aws-sdk/core": "^3.973.26", + "@aws-sdk/credential-provider-cognito-identity": "^3.972.21", + "@aws-sdk/credential-provider-env": "^3.972.24", + "@aws-sdk/credential-provider-http": "^3.972.26", + "@aws-sdk/credential-provider-ini": "^3.972.28", + "@aws-sdk/credential-provider-login": "^3.972.28", + "@aws-sdk/credential-provider-node": "^3.972.29", + "@aws-sdk/credential-provider-process": "^3.972.24", + "@aws-sdk/credential-provider-sso": "^3.972.28", + "@aws-sdk/credential-provider-web-identity": "^3.972.28", + "@aws-sdk/nested-clients": "^3.996.18", + "@aws-sdk/types": "^3.973.6", + "@smithy/config-resolver": "^4.4.13", + "@smithy/core": "^3.23.13", + "@smithy/credential-provider-imds": "^4.2.12", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/property-provider": "^4.2.12", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -1271,15 +1271,15 @@ } }, "node_modules/@aws-sdk/dynamodb-codec": { - "version": "3.972.20", - "resolved": "https://registry.npmjs.org/@aws-sdk/dynamodb-codec/-/dynamodb-codec-3.972.20.tgz", - "integrity": "sha512-MQ2W0zeBMNaQYgHcQ7aul7g5783qFdP2AKcJnpaID0ekl2QbiKF+St1JMx5lgOXHlnERD9X3exr2B0SIg35oOA==", + "version": "3.972.27", + "resolved": "https://registry.npmjs.org/@aws-sdk/dynamodb-codec/-/dynamodb-codec-3.972.27.tgz", + "integrity": "sha512-S7IWE0K+aqbvjP8PHnOyDJK1fzrazAismH5XutJtS3YBvRvmfLb8Ac7Z1ZC4LBWvO8Gx1t/szFe46K51FqZn/A==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.19", - "@smithy/core": "^3.23.9", - "@smithy/smithy-client": "^4.12.3", - "@smithy/types": "^4.13.0", + "@aws-sdk/core": "^3.973.26", + "@smithy/core": "^3.23.13", + "@smithy/smithy-client": "^4.12.8", + "@smithy/types": "^4.13.1", "@smithy/util-base64": "^4.3.2", "tslib": "^2.6.2" }, @@ -1288,9 +1288,9 @@ } }, "node_modules/@aws-sdk/endpoint-cache": { - "version": "3.972.4", - "resolved": "https://registry.npmjs.org/@aws-sdk/endpoint-cache/-/endpoint-cache-3.972.4.tgz", - "integrity": "sha512-GdASDnWanLnHxKK0hqV97xz23QmfA/C8yGe0PiuEmWiHSe+x+x+mFEj4sXqx9IbfyPncWz8f4EhNwBSG9cgYCg==", + "version": "3.972.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/endpoint-cache/-/endpoint-cache-3.972.5.tgz", + "integrity": "sha512-itVdge0NozgtgmtbZ25FVwWU3vGlE7x7feE/aOEJNkQfEpbkrF8Rj1QmnK+2blFfYE1xWt/iU+6/jUp/pv1+MA==", "license": "Apache-2.0", "dependencies": { "mnemonist": "0.38.3", @@ -1301,17 +1301,17 @@ } }, "node_modules/@aws-sdk/middleware-bucket-endpoint": { - "version": "3.972.7", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.972.7.tgz", - "integrity": "sha512-goX+axlJ6PQlRnzE2bQisZ8wVrlm6dXJfBzMJhd8LhAIBan/w1Kl73fJnalM/S+18VnpzIHumyV6DtgmvqG5IA==", + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.972.8.tgz", + "integrity": "sha512-WR525Rr2QJSETa9a050isktyWi/4yIGcmY3BQ1kpHqb0LqUglQHCS8R27dTJxxWNZvQ0RVGtEZjTCbZJpyF3Aw==", "license": "Apache-2.0", "peer": true, "dependencies": { - "@aws-sdk/types": "^3.973.5", + "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-arn-parser": "^3.972.3", - "@smithy/node-config-provider": "^4.3.11", - "@smithy/protocol-http": "^5.3.11", - "@smithy/types": "^4.13.0", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" }, @@ -1320,16 +1320,16 @@ } }, "node_modules/@aws-sdk/middleware-endpoint-discovery": { - "version": "3.972.7", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-endpoint-discovery/-/middleware-endpoint-discovery-3.972.7.tgz", - "integrity": "sha512-ZeFfgAVOGR+fDq/JAPsVA3P07ba74hIppoGfmQyfzZMfAQAzc9Lbg5pndZU8EanzfKnlXbv6y09OMrSkTsUuOg==", + "version": "3.972.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-endpoint-discovery/-/middleware-endpoint-discovery-3.972.9.tgz", + "integrity": "sha512-1503Y5Xk14SdXY0ucXwc08CY+aVuoY1tmQxsR/apwAVAwcLT7FFzqjYJYLq8JOkKJyzIB8M6J27e1ZcagGK+Fg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/endpoint-cache": "^3.972.4", - "@aws-sdk/types": "^3.973.5", - "@smithy/node-config-provider": "^4.3.11", - "@smithy/protocol-http": "^5.3.11", - "@smithy/types": "^4.13.0", + "@aws-sdk/endpoint-cache": "^3.972.5", + "@aws-sdk/types": "^3.973.6", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -1337,15 +1337,15 @@ } }, "node_modules/@aws-sdk/middleware-expect-continue": { - "version": "3.972.7", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.972.7.tgz", - "integrity": "sha512-mvWqvm61bmZUKmmrtl2uWbokqpenY3Mc3Jf4nXB/Hse6gWxLPaCQThmhPBDzsPSV8/Odn8V6ovWt3pZ7vy4BFQ==", + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.972.8.tgz", + "integrity": "sha512-5DTBTiotEES1e2jOHAq//zyzCjeMB78lEHd35u15qnrid4Nxm7diqIf9fQQ3Ov0ChH1V3Vvt13thOnrACmfGVQ==", "license": "Apache-2.0", "peer": true, "dependencies": { - "@aws-sdk/types": "^3.973.5", - "@smithy/protocol-http": "^5.3.11", - "@smithy/types": "^4.13.0", + "@aws-sdk/types": "^3.973.6", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -1353,24 +1353,24 @@ } }, "node_modules/@aws-sdk/middleware-flexible-checksums": { - "version": "3.973.5", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.973.5.tgz", - "integrity": "sha512-Dp3hqE5W6hG8HQ3Uh+AINx9wjjqYmFHbxede54sGj3akx/haIQrkp85lNdTdC+ouNUcSYNiuGkzmyDREfHX1Gg==", + "version": "3.974.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.974.6.tgz", + "integrity": "sha512-YckB8k1ejbyCg/g36gUMFLNzE4W5cERIa4MtsdO+wpTmJEP0+TB7okWIt7d8TDOvnb7SwvxJ21E4TGOBxFpSWQ==", "license": "Apache-2.0", "peer": true, "dependencies": { "@aws-crypto/crc32": "5.2.0", "@aws-crypto/crc32c": "5.2.0", "@aws-crypto/util": "5.2.0", - "@aws-sdk/core": "^3.973.19", - "@aws-sdk/crc64-nvme": "^3.972.4", - "@aws-sdk/types": "^3.973.5", + "@aws-sdk/core": "^3.973.26", + "@aws-sdk/crc64-nvme": "^3.972.5", + "@aws-sdk/types": "^3.973.6", "@smithy/is-array-buffer": "^4.2.2", - "@smithy/node-config-provider": "^4.3.11", - "@smithy/protocol-http": "^5.3.11", - "@smithy/types": "^4.13.0", - "@smithy/util-middleware": "^4.2.11", - "@smithy/util-stream": "^4.5.17", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-stream": "^4.5.21", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -1379,14 +1379,14 @@ } }, "node_modules/@aws-sdk/middleware-host-header": { - "version": "3.972.7", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.7.tgz", - "integrity": "sha512-aHQZgztBFEpDU1BB00VWCIIm85JjGjQW1OG9+98BdmaOpguJvzmXBGbnAiYcciCd+IS4e9BEq664lhzGnWJHgQ==", + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.8.tgz", + "integrity": "sha512-wAr2REfKsqoKQ+OkNqvOShnBoh+nkPurDKW7uAeVSu6kUECnWlSJiPvnoqxGlfousEY/v9LfS9sNc46hjSYDIQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.5", - "@smithy/protocol-http": "^5.3.11", - "@smithy/types": "^4.13.0", + "@aws-sdk/types": "^3.973.6", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -1394,14 +1394,14 @@ } }, "node_modules/@aws-sdk/middleware-location-constraint": { - "version": "3.972.7", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.972.7.tgz", - "integrity": "sha512-vdK1LJfffBp87Lj0Bw3WdK1rJk9OLDYdQpqoKgmpIZPe+4+HawZ6THTbvjhJt4C4MNnRrHTKHQjkwBiIpDBoig==", + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.972.8.tgz", + "integrity": "sha512-KaUoFuoFPziIa98DSQsTPeke1gvGXlc5ZGMhy+b+nLxZ4A7jmJgLzjEF95l8aOQN2T/qlPP3MrAyELm8ExXucw==", "license": "Apache-2.0", "peer": true, "dependencies": { - "@aws-sdk/types": "^3.973.5", - "@smithy/types": "^4.13.0", + "@aws-sdk/types": "^3.973.6", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -1409,13 +1409,13 @@ } }, "node_modules/@aws-sdk/middleware-logger": { - "version": "3.972.7", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.7.tgz", - "integrity": "sha512-LXhiWlWb26txCU1vcI9PneESSeRp/RYY/McuM4SpdrimQR5NgwaPb4VJCadVeuGWgh6QmqZ6rAKSoL1ob16W6w==", + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.8.tgz", + "integrity": "sha512-CWl5UCM57WUFaFi5kB7IBY1UmOeLvNZAZ2/OZ5l20ldiJ3TiIz1pC65gYj8X0BCPWkeR1E32mpsCk1L1I4n+lA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.5", - "@smithy/types": "^4.13.0", + "@aws-sdk/types": "^3.973.6", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -1423,15 +1423,15 @@ } }, "node_modules/@aws-sdk/middleware-recursion-detection": { - "version": "3.972.7", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.7.tgz", - "integrity": "sha512-l2VQdcBcYLzIzykCHtXlbpiVCZ94/xniLIkAj0jpnpjY4xlgZx7f56Ypn+uV1y3gG0tNVytJqo3K9bfMFee7SQ==", + "version": "3.972.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.9.tgz", + "integrity": "sha512-/Wt5+CT8dpTFQxEJ9iGy/UGrXr7p2wlIOEHvIr/YcHYByzoLjrqkYqXdJjd9UIgWjv7eqV2HnFJen93UTuwfTQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.5", + "@aws-sdk/types": "^3.973.6", "@aws/lambda-invoke-store": "^0.2.2", - "@smithy/protocol-http": "^5.3.11", - "@smithy/types": "^4.13.0", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -1439,24 +1439,24 @@ } }, "node_modules/@aws-sdk/middleware-sdk-s3": { - "version": "3.972.19", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.19.tgz", - "integrity": "sha512-/CtOHHVFg4ZuN6CnLnYkrqWgVEnbOBC4kNiKa+4fldJ9cioDt3dD/f5vpq0cWLOXwmGL2zgVrVxNhjxWpxNMkg==", + "version": "3.972.27", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.27.tgz", + "integrity": "sha512-gomO6DZwx+1D/9mbCpcqO5tPBqYBK7DtdgjTIjZ4yvfh/S7ETwAPS0XbJgP2JD8Ycr5CwVrEkV1sFtu3ShXeOw==", "license": "Apache-2.0", "peer": true, "dependencies": { - "@aws-sdk/core": "^3.973.19", - "@aws-sdk/types": "^3.973.5", + "@aws-sdk/core": "^3.973.26", + "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-arn-parser": "^3.972.3", - "@smithy/core": "^3.23.9", - "@smithy/node-config-provider": "^4.3.11", - "@smithy/protocol-http": "^5.3.11", - "@smithy/signature-v4": "^5.3.11", - "@smithy/smithy-client": "^4.12.3", - "@smithy/types": "^4.13.0", + "@smithy/core": "^3.23.13", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/signature-v4": "^5.3.12", + "@smithy/smithy-client": "^4.12.8", + "@smithy/types": "^4.13.1", "@smithy/util-config-provider": "^4.2.2", - "@smithy/util-middleware": "^4.2.11", - "@smithy/util-stream": "^4.5.17", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-stream": "^4.5.21", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -1465,15 +1465,15 @@ } }, "node_modules/@aws-sdk/middleware-sdk-sqs": { - "version": "3.972.14", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-sqs/-/middleware-sdk-sqs-3.972.14.tgz", - "integrity": "sha512-MmN/j0D3MLkR0cca8/V2GXjGAkcgp1tlrQZZduLb6G+UhfOJuzFW3rSrCeiXTBgiXSIIZ6sc/gsuACpg/5TL1Q==", + "version": "3.972.18", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-sqs/-/middleware-sdk-sqs-3.972.18.tgz", + "integrity": "sha512-BdsGFuBJUX5PnuZkEV6JRB5g/6ts7iGmN3pXwyoiGCCM2HHXrlFqjkBs+iPX7yO884WqYeQJpme7nwn4DzU5xw==", "license": "Apache-2.0", "peer": true, "dependencies": { - "@aws-sdk/types": "^3.973.5", - "@smithy/smithy-client": "^4.12.3", - "@smithy/types": "^4.13.0", + "@aws-sdk/types": "^3.973.6", + "@smithy/smithy-client": "^4.12.8", + "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" @@ -1483,14 +1483,14 @@ } }, "node_modules/@aws-sdk/middleware-ssec": { - "version": "3.972.7", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.972.7.tgz", - "integrity": "sha512-G9clGVuAml7d8DYzY6DnRi7TIIDRvZ3YpqJPz/8wnWS5fYx/FNWNmkO6iJVlVkQg9BfeMzd+bVPtPJOvC4B+nQ==", + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.972.8.tgz", + "integrity": "sha512-wqlK0yO/TxEC2UsY9wIlqeeutF6jjLe0f96Pbm40XscTo57nImUk9lBcw0dPgsm0sppFtAkSlDrfpK+pC30Wqw==", "license": "Apache-2.0", "peer": true, "dependencies": { - "@aws-sdk/types": "^3.973.5", - "@smithy/types": "^4.13.0", + "@aws-sdk/types": "^3.973.6", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -1498,18 +1498,18 @@ } }, "node_modules/@aws-sdk/middleware-user-agent": { - "version": "3.972.20", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.20.tgz", - "integrity": "sha512-3kNTLtpUdeahxtnJRnj/oIdLAUdzTfr9N40KtxNhtdrq+Q1RPMdCJINRXq37m4t5+r3H70wgC3opW46OzFcZYA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.973.19", - "@aws-sdk/types": "^3.973.5", - "@aws-sdk/util-endpoints": "^3.996.4", - "@smithy/core": "^3.23.9", - "@smithy/protocol-http": "^5.3.11", - "@smithy/types": "^4.13.0", - "@smithy/util-retry": "^4.2.11", + "version": "3.972.28", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.28.tgz", + "integrity": "sha512-cfWZFlVh7Va9lRay4PN2A9ARFzaBYcA097InT5M2CdRS05ECF5yaz86jET8Wsl2WcyKYEvVr/QNmKtYtafUHtQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.26", + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-endpoints": "^3.996.5", + "@smithy/core": "^3.23.13", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-retry": "^4.2.13", "tslib": "^2.6.2" }, "engines": { @@ -1517,47 +1517,47 @@ } }, "node_modules/@aws-sdk/nested-clients": { - "version": "3.996.9", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.996.9.tgz", - "integrity": "sha512-+RpVtpmQbbtzFOKhMlsRcXM/3f1Z49qTOHaA8gEpHOYruERmog6f2AUtf/oTRLCWjR9H2b3roqryV/hI7QMW8w==", + "version": "3.996.18", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.996.18.tgz", + "integrity": "sha512-c7ZSIXrESxHKx2Mcopgd8AlzZgoXMr20fkx5ViPWPOLBvmyhw9VwJx/Govg8Ef/IhEon5R9l53Z8fdYSEmp6VA==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.19", - "@aws-sdk/middleware-host-header": "^3.972.7", - "@aws-sdk/middleware-logger": "^3.972.7", - "@aws-sdk/middleware-recursion-detection": "^3.972.7", - "@aws-sdk/middleware-user-agent": "^3.972.20", - "@aws-sdk/region-config-resolver": "^3.972.7", - "@aws-sdk/types": "^3.973.5", - "@aws-sdk/util-endpoints": "^3.996.4", - "@aws-sdk/util-user-agent-browser": "^3.972.7", - "@aws-sdk/util-user-agent-node": "^3.973.6", - "@smithy/config-resolver": "^4.4.10", - "@smithy/core": "^3.23.9", - "@smithy/fetch-http-handler": "^5.3.13", - "@smithy/hash-node": "^4.2.11", - "@smithy/invalid-dependency": "^4.2.11", - "@smithy/middleware-content-length": "^4.2.11", - "@smithy/middleware-endpoint": "^4.4.23", - "@smithy/middleware-retry": "^4.4.40", - "@smithy/middleware-serde": "^4.2.12", - "@smithy/middleware-stack": "^4.2.11", - "@smithy/node-config-provider": "^4.3.11", - "@smithy/node-http-handler": "^4.4.14", - "@smithy/protocol-http": "^5.3.11", - "@smithy/smithy-client": "^4.12.3", - "@smithy/types": "^4.13.0", - "@smithy/url-parser": "^4.2.11", + "@aws-sdk/core": "^3.973.26", + "@aws-sdk/middleware-host-header": "^3.972.8", + "@aws-sdk/middleware-logger": "^3.972.8", + "@aws-sdk/middleware-recursion-detection": "^3.972.9", + "@aws-sdk/middleware-user-agent": "^3.972.28", + "@aws-sdk/region-config-resolver": "^3.972.10", + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-endpoints": "^3.996.5", + "@aws-sdk/util-user-agent-browser": "^3.972.8", + "@aws-sdk/util-user-agent-node": "^3.973.14", + "@smithy/config-resolver": "^4.4.13", + "@smithy/core": "^3.23.13", + "@smithy/fetch-http-handler": "^5.3.15", + "@smithy/hash-node": "^4.2.12", + "@smithy/invalid-dependency": "^4.2.12", + "@smithy/middleware-content-length": "^4.2.12", + "@smithy/middleware-endpoint": "^4.4.28", + "@smithy/middleware-retry": "^4.4.46", + "@smithy/middleware-serde": "^4.2.16", + "@smithy/middleware-stack": "^4.2.12", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/node-http-handler": "^4.5.1", + "@smithy/protocol-http": "^5.3.12", + "@smithy/smithy-client": "^4.12.8", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", - "@smithy/util-defaults-mode-browser": "^4.3.39", - "@smithy/util-defaults-mode-node": "^4.2.42", - "@smithy/util-endpoints": "^3.3.2", - "@smithy/util-middleware": "^4.2.11", - "@smithy/util-retry": "^4.2.11", + "@smithy/util-defaults-mode-browser": "^4.3.44", + "@smithy/util-defaults-mode-node": "^4.2.48", + "@smithy/util-endpoints": "^3.3.3", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-retry": "^4.2.13", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -1566,15 +1566,15 @@ } }, "node_modules/@aws-sdk/region-config-resolver": { - "version": "3.972.7", - "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.7.tgz", - "integrity": "sha512-/Ev/6AI8bvt4HAAptzSjThGUMjcWaX3GX8oERkB0F0F9x2dLSBdgFDiyrRz3i0u0ZFZFQ1b28is4QhyqXTUsVA==", + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.10.tgz", + "integrity": "sha512-1dq9ToC6e070QvnVhhbAs3bb5r6cQ10gTVc6cyRV5uvQe7P138TV2uG2i6+Yok4bAkVAcx5AqkTEBUvWEtBlsQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.5", - "@smithy/config-resolver": "^4.4.10", - "@smithy/node-config-provider": "^4.3.11", - "@smithy/types": "^4.13.0", + "@aws-sdk/types": "^3.973.6", + "@smithy/config-resolver": "^4.4.13", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -1582,19 +1582,19 @@ } }, "node_modules/@aws-sdk/s3-request-presigner": { - "version": "3.1008.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1008.0.tgz", - "integrity": "sha512-YZMG/5X2TVegzLjw6H5MIIeAUlp+JtkomKOITIZ9P9XS21hRZthRmFO4eJZe0xVLGfuMYZPUYSsiD2eEQuWdQw==", + "version": "3.1021.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1021.0.tgz", + "integrity": "sha512-kkIzsIAc7wnG7vVRkZFIwJ3noOyF3S6ozOQ9t2KxzPde1LsmpmPwYbmiB91DzdfuGySdk4Hpb0JmHh4KhGECXQ==", "license": "Apache-2.0", "peer": true, "dependencies": { - "@aws-sdk/signature-v4-multi-region": "^3.996.7", - "@aws-sdk/types": "^3.973.5", - "@aws-sdk/util-format-url": "^3.972.7", - "@smithy/middleware-endpoint": "^4.4.23", - "@smithy/protocol-http": "^5.3.11", - "@smithy/smithy-client": "^4.12.3", - "@smithy/types": "^4.13.0", + "@aws-sdk/signature-v4-multi-region": "^3.996.15", + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-format-url": "^3.972.8", + "@smithy/middleware-endpoint": "^4.4.28", + "@smithy/protocol-http": "^5.3.12", + "@smithy/smithy-client": "^4.12.8", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -1602,17 +1602,17 @@ } }, "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.996.7", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.7.tgz", - "integrity": "sha512-mYhh7FY+7OOqjkYkd6+6GgJOsXK1xBWmuR+c5mxJPj2kr5TBNeZq+nUvE9kANWAux5UxDVrNOSiEM/wlHzC3Lg==", + "version": "3.996.15", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.15.tgz", + "integrity": "sha512-Ukw2RpqvaL96CjfH/FgfBmy/ZosHBqoHBCFsN61qGg99F33vpntIVii8aNeh65XuOja73arSduskoa4OJea9RQ==", "license": "Apache-2.0", "peer": true, "dependencies": { - "@aws-sdk/middleware-sdk-s3": "^3.972.19", - "@aws-sdk/types": "^3.973.5", - "@smithy/protocol-http": "^5.3.11", - "@smithy/signature-v4": "^5.3.11", - "@smithy/types": "^4.13.0", + "@aws-sdk/middleware-sdk-s3": "^3.972.27", + "@aws-sdk/types": "^3.973.6", + "@smithy/protocol-http": "^5.3.12", + "@smithy/signature-v4": "^5.3.12", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -1620,17 +1620,17 @@ } }, "node_modules/@aws-sdk/token-providers": { - "version": "3.1008.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1008.0.tgz", - "integrity": "sha512-TulwlHQBWcJs668kNUDMZHN51DeLrDsYT59Ux4a/nbvr025gM6HjKJJ3LvnZccam7OS/ZKUVkWomCneRQKJbBg==", + "version": "3.1021.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1021.0.tgz", + "integrity": "sha512-TKY6h9spUk3OLs5v1oAgW9mAeBE3LAGNBwJokLy96wwmd4W2v/tYlXseProyed9ValDj2u1jK/4Rg1T+1NXyJA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.19", - "@aws-sdk/nested-clients": "^3.996.9", - "@aws-sdk/types": "^3.973.5", - "@smithy/property-provider": "^4.2.11", - "@smithy/shared-ini-file-loader": "^4.4.6", - "@smithy/types": "^4.13.0", + "@aws-sdk/core": "^3.973.26", + "@aws-sdk/nested-clients": "^3.996.18", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -1638,12 +1638,12 @@ } }, "node_modules/@aws-sdk/types": { - "version": "3.973.5", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.5.tgz", - "integrity": "sha512-hl7BGwDCWsjH8NkZfx+HgS7H2LyM2lTMAI7ba9c8O0KqdBLTdNJivsHpqjg9rNlAlPyREb6DeDRXUl0s8uFdmQ==", + "version": "3.973.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.6.tgz", + "integrity": "sha512-Atfcy4E++beKtwJHiDln2Nby8W/mam64opFPTiHEqgsthqeydFS1pY+OUlN1ouNOmf8ArPU/6cDS65anOP3KQw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.0", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -1679,15 +1679,15 @@ } }, "node_modules/@aws-sdk/util-endpoints": { - "version": "3.996.4", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.4.tgz", - "integrity": "sha512-Hek90FBmd4joCFj+Vc98KLJh73Zqj3s2W56gjAcTkrNLMDI5nIFkG9YpfcJiVI1YlE2Ne1uOQNe+IgQ/Vz2XRA==", + "version": "3.996.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.5.tgz", + "integrity": "sha512-Uh93L5sXFNbyR5sEPMzUU8tJ++Ku97EY4udmC01nB8Zu+xfBPwpIwJ6F7snqQeq8h2pf+8SGN5/NoytfKgYPIw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.5", - "@smithy/types": "^4.13.0", - "@smithy/url-parser": "^4.2.11", - "@smithy/util-endpoints": "^3.3.2", + "@aws-sdk/types": "^3.973.6", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", + "@smithy/util-endpoints": "^3.3.3", "tslib": "^2.6.2" }, "engines": { @@ -1695,15 +1695,15 @@ } }, "node_modules/@aws-sdk/util-format-url": { - "version": "3.972.7", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-format-url/-/util-format-url-3.972.7.tgz", - "integrity": "sha512-V+PbnWfUl93GuFwsOHsAq7hY/fnm9kElRqR8IexIJr5Rvif9e614X5sGSyz3mVSf1YAZ+VTy63W1/pGdA55zyA==", + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-format-url/-/util-format-url-3.972.8.tgz", + "integrity": "sha512-J6DS9oocrgxM8xlUTTmQOuwRF6rnAGEujAN9SAzllcrQmwn5iJ58ogxy3SEhD0Q7JZvlA5jvIXBkpQRqEqlE9A==", "license": "Apache-2.0", "peer": true, "dependencies": { - "@aws-sdk/types": "^3.973.5", - "@smithy/querystring-builder": "^4.2.11", - "@smithy/types": "^4.13.0", + "@aws-sdk/types": "^3.973.6", + "@smithy/querystring-builder": "^4.2.12", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -1723,27 +1723,27 @@ } }, "node_modules/@aws-sdk/util-user-agent-browser": { - "version": "3.972.7", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.7.tgz", - "integrity": "sha512-7SJVuvhKhMF/BkNS1n0QAJYgvEwYbK2QLKBrzDiwQGiTRU6Yf1f3nehTzm/l21xdAOtWSfp2uWSddPnP2ZtsVw==", + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.8.tgz", + "integrity": "sha512-B3KGXJviV2u6Cdw2SDY2aDhoJkVfY/Q/Trwk2CMSkikE1Oi6gRzxhvhIfiRpHfmIsAhV4EA54TVEX8K6CbHbkA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.5", - "@smithy/types": "^4.13.0", + "@aws-sdk/types": "^3.973.6", + "@smithy/types": "^4.13.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "node_modules/@aws-sdk/util-user-agent-node": { - "version": "3.973.6", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.6.tgz", - "integrity": "sha512-iF7G0prk7AvmOK64FcLvc/fW+Ty1H+vttajL7PvJFReU8urMxfYmynTTuFKDTA76Wgpq3FzTPKwabMQIXQHiXQ==", + "version": "3.973.14", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.14.tgz", + "integrity": "sha512-vNSB/DYaPOyujVZBg/zUznH9QC142MaTHVmaFlF7uzzfg3CgT9f/l4C0Yi+vU/tbBhxVcXVB90Oohk5+o+ZbWw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/middleware-user-agent": "^3.972.20", - "@aws-sdk/types": "^3.973.5", - "@smithy/node-config-provider": "^4.3.11", - "@smithy/types": "^4.13.0", + "@aws-sdk/middleware-user-agent": "^3.972.28", + "@aws-sdk/types": "^3.973.6", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/types": "^4.13.1", "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" }, @@ -1760,13 +1760,13 @@ } }, "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.10", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.10.tgz", - "integrity": "sha512-OnejAIVD+CxzyAUrVic7lG+3QRltyja9LoNqCE/1YVs8ichoTbJlVSaZ9iSMcnHLyzrSNtvaOGjSDRP+d/ouFA==", + "version": "3.972.16", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.16.tgz", + "integrity": "sha512-iu2pyvaqmeatIJLURLqx9D+4jKAdTH20ntzB6BFwjyN7V960r4jK32mx0Zf7YbtOYAbmbtQfDNuL60ONinyw7A==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.0", - "fast-xml-parser": "5.4.1", + "@smithy/types": "^4.13.1", + "fast-xml-parser": "5.5.8", "tslib": "^2.6.2" }, "engines": { @@ -1965,23 +1965,23 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", - "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", "dev": true, "license": "MIT", "dependencies": { "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/types": "^7.29.0" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", "dev": true, "license": "MIT", "dependencies": { @@ -2250,9 +2250,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", - "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -2306,6 +2306,13 @@ "node": ">=6.9.0" } }, + "node_modules/@balena/dockerignore": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@balena/dockerignore/-/dockerignore-1.0.2.tgz", + "integrity": "sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@bcoe/v8-coverage": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", @@ -2349,9 +2356,9 @@ } }, "node_modules/@dmptool/utils": { - "version": "1.0.42", - "resolved": "https://registry.npmjs.org/@dmptool/utils/-/utils-1.0.42.tgz", - "integrity": "sha512-Y9p6BVjGlQHw8xq4JqFdIpMZ3RoJauOn8RvDdzkGOiHQY19qy0e0CvLFEulkq4Wh6M3glOtH2cUIXEmpUQjeCQ==", + "version": "1.0.43", + "resolved": "https://registry.npmjs.org/@dmptool/utils/-/utils-1.0.43.tgz", + "integrity": "sha512-AL/5bhtFRMd3TcKoyLb16cm5xeMdSW9xB7alraCADmcEcyHwcfdF2HyyKfonqDIFnQa7/9CoE+oTLP5phQCZZw==", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -2364,15 +2371,15 @@ "pino-lambda": "^4.4.1" }, "peerDependencies": { - "@aws-sdk/client-cloudformation": "^3.1006.0", - "@aws-sdk/client-dynamodb": "^3.1006.0", - "@aws-sdk/client-s3": "^3.1006.0", - "@aws-sdk/client-sns": "^3.1006.0", - "@aws-sdk/client-sqs": "^3.1006.0", - "@aws-sdk/client-ssm": "^3.1006.0", - "@aws-sdk/s3-request-presigner": "^3.1006.0", - "@aws-sdk/util-dynamodb": "^3.199.0", - "@smithy/node-http-handler": "^4.4.14" + "@aws-sdk/client-cloudformation": "^3.1011.0", + "@aws-sdk/client-dynamodb": "^3.1011.0", + "@aws-sdk/client-s3": "^3.1011.0", + "@aws-sdk/client-sns": "^3.1011.0", + "@aws-sdk/client-sqs": "^3.1011.0", + "@aws-sdk/client-ssm": "^3.1011.0", + "@aws-sdk/s3-request-presigner": "^3.1011.0", + "@aws-sdk/util-dynamodb": "^3.996.2", + "@smithy/node-http-handler": "^4.5.0" } }, "node_modules/@elastic/ecs-helpers": { @@ -2397,9 +2404,9 @@ } }, "node_modules/@emnapi/core": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz", - "integrity": "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==", + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", + "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", "dev": true, "license": "MIT", "optional": true, @@ -2409,9 +2416,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz", - "integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==", + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", + "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", "dev": true, "license": "MIT", "optional": true, @@ -3025,9 +3032,9 @@ } }, "node_modules/@graphql-tools/batch-execute": { - "version": "10.0.5", - "resolved": "https://registry.npmjs.org/@graphql-tools/batch-execute/-/batch-execute-10.0.5.tgz", - "integrity": "sha512-dL13tXkfGvAzLq2XfzTKAy9logIcltKYRuPketxdh3Ok3U6PN1HKMCHfrE9cmtAsxD96/8Hlghz5AtM+LRv/ig==", + "version": "10.0.7", + "resolved": "https://registry.npmjs.org/@graphql-tools/batch-execute/-/batch-execute-10.0.7.tgz", + "integrity": "sha512-vKo9XUiy2sc5tzMupXoxZbu5afVY/9yJ0+yLrM5Dhh38yHYULf3z9VC1eAwW0kj8pWpOo8d8CV3jpleGwv83PA==", "dev": true, "license": "MIT", "dependencies": { @@ -3064,13 +3071,13 @@ } }, "node_modules/@graphql-tools/delegate": { - "version": "12.0.9", - "resolved": "https://registry.npmjs.org/@graphql-tools/delegate/-/delegate-12.0.9.tgz", - "integrity": "sha512-ugJCiJb4w3bmdUbAz+nKyVVuwzzoy6BZHQ4BJXpAx1i5KIEhSevIkMYq3CZo7drCZf6FMIpcKBxv99uIhzCvhA==", + "version": "12.0.12", + "resolved": "https://registry.npmjs.org/@graphql-tools/delegate/-/delegate-12.0.12.tgz", + "integrity": "sha512-/vgLWhIwm+Mgo5VUOJQj6EOpaxXRQmA7mk8j6/8vBbPi56LoYA/UPRygcpEnm9EuXTspFKCTBil+xqThU3EmqQ==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-tools/batch-execute": "^10.0.5", + "@graphql-tools/batch-execute": "^10.0.7", "@graphql-tools/executor": "^1.4.13", "@graphql-tools/schema": "^10.0.29", "@graphql-tools/utils": "^11.0.0", @@ -3142,9 +3149,9 @@ } }, "node_modules/@graphql-tools/executor-graphql-ws": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/@graphql-tools/executor-graphql-ws/-/executor-graphql-ws-3.1.4.tgz", - "integrity": "sha512-wCQfWYLwg1JZmQ7rGaFy74AQyVFxpeqz19WWIGRgANiYlm+T0K3Hs6POgi0+nL3HvwxJIxhUlaRLFvkqm1zxSA==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@graphql-tools/executor-graphql-ws/-/executor-graphql-ws-3.1.5.tgz", + "integrity": "sha512-WXRsfwu9AkrORD9nShrd61OwwxeQ5+eXYcABRR3XPONFIS8pWQfDJGGqxql9/227o/s0DV5SIfkBURb5Knzv+A==", "dev": true, "license": "MIT", "dependencies": { @@ -3164,9 +3171,9 @@ } }, "node_modules/@graphql-tools/executor-http": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@graphql-tools/executor-http/-/executor-http-3.1.0.tgz", - "integrity": "sha512-DTaNU1rT2sxffwQlt+Aw68cHQWfGkjsaRk1D8nvG+DcCR8RNQo0d9qYt7pXIcfXYcQLb/OkABcGSuCfkopvHJg==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@graphql-tools/executor-http/-/executor-http-3.1.4.tgz", + "integrity": "sha512-KOVSJo4WlMBgbJEIl3Fnv0DNmdZOAOKsJ9UfH4fUbxM1bDRBVHN4WM1au+JlK1sH00Uw0WRzsXXw4iquePe2tA==", "dev": true, "license": "MIT", "dependencies": { @@ -3479,13 +3486,13 @@ } }, "node_modules/@graphql-tools/wrap": { - "version": "11.1.9", - "resolved": "https://registry.npmjs.org/@graphql-tools/wrap/-/wrap-11.1.9.tgz", - "integrity": "sha512-dSjBNCTPS8W7lWHDgIEgY0MEDwZi1GkqTfl7bJMDs/dJfKojj4Do74LN5QJbP/bIIwJakEwVUMPA8RUYVBP9eQ==", + "version": "11.1.12", + "resolved": "https://registry.npmjs.org/@graphql-tools/wrap/-/wrap-11.1.12.tgz", + "integrity": "sha512-PJ0tuiGbEOOZAJk2/pTKyzMEbwBncPBfO7Z84tCPzM/CAR4ZlAXbXjaXOw4fdi0ReUDyOG06Z8DGgEQjr68dKw==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-tools/delegate": "^12.0.9", + "@graphql-tools/delegate": "^12.0.12", "@graphql-tools/schema": "^10.0.29", "@graphql-tools/utils": "^11.0.0", "@whatwg-node/promise-helpers": "^1.3.2", @@ -3507,6 +3514,72 @@ "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, + "node_modules/@grpc/grpc-js": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz", + "integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/grpc-js/node_modules/@grpc/proto-loader": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.0.tgz", + "integrity": "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.3", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@grpc/grpc-js/node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@grpc/proto-loader": { + "version": "0.7.15", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", + "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.2.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@grpc/proto-loader/node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -4455,6 +4528,17 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, "node_modules/@keyv/redis": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/@keyv/redis/-/redis-5.1.6.tgz", @@ -4478,6 +4562,16 @@ "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", "license": "MIT" }, + "node_modules/@kwsites/file-exists": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz", + "integrity": "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1" + } + }, "node_modules/@mswjs/interceptors": { "version": "0.41.3", "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.3.tgz", @@ -4770,9 +4864,9 @@ "license": "MIT" }, "node_modules/@sinclair/typebox": { - "version": "0.34.48", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz", - "integrity": "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==", + "version": "0.34.49", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", + "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", "dev": true, "license": "MIT" }, @@ -4787,28 +4881,15 @@ } }, "node_modules/@sinonjs/fake-timers": { - "version": "15.1.1", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.1.1.tgz", - "integrity": "sha512-cO5W33JgAPbOh07tvZjUOJ7oWhtaqGHiZw+11DPbyqh2kHTBc3eF/CjJDeQ4205RLQsX6rxCuYOroFQwl7JDRw==", + "version": "15.2.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.2.0.tgz", + "integrity": "sha512-+SM3gQi95RWZLlD+Npy/UC5mHftlXwnVJMRpMyiqjrF4yNnbvi/Ubh3x9sLw6gxWSuibOn00uiLu1CKozehWlQ==", "dev": true, "license": "BSD-3-Clause", "dependencies": { "@sinonjs/commons": "^3.0.1" } }, - "node_modules/@smithy/abort-controller": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.12.tgz", - "integrity": "sha512-xolrFw6b+2iYGl6EcOL7IJY71vvyZ0DJ3mcKtpykqPe2uscwtzDZJa1uVQXyP7w9Dd+kGwYnPbMsJrGISKiY/Q==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@smithy/chunked-blob-reader": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader/-/chunked-blob-reader-5.2.2.tgz", @@ -4837,9 +4918,9 @@ } }, "node_modules/@smithy/config-resolver": { - "version": "4.4.11", - "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.11.tgz", - "integrity": "sha512-YxFiiG4YDAtX7WMN7RuhHZLeTmRRAOyCbr+zB8e3AQzHPnUhS8zXjB1+cniPVQI3xbWsQPM0X2aaIkO/ME0ymw==", + "version": "4.4.13", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.13.tgz", + "integrity": "sha512-iIzMC5NmOUP6WL6o8iPBjFhUhBZ9pPjpUpQYWMUFQqKyXXzOftbfK8zcQCz/jFV1Psmf05BK5ypx4K2r4Tnwdg==", "license": "Apache-2.0", "dependencies": { "@smithy/node-config-provider": "^4.3.12", @@ -4854,9 +4935,9 @@ } }, "node_modules/@smithy/core": { - "version": "3.23.11", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.11.tgz", - "integrity": "sha512-952rGf7hBRnhUIaeLp6q4MptKW8sPFe5VvkoZ5qIzFAtx6c/QZ/54FS3yootsyUSf9gJX/NBqEBNdNR7jMIlpQ==", + "version": "3.23.13", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.13.tgz", + "integrity": "sha512-J+2TT9D6oGsUVXVEMvz8h2EmdVnkBiy2auCie4aSJMvKlzUtO5hqjEzXhoCUkIMo7gAYjbQcN0g/MMSXEhDs1Q==", "license": "Apache-2.0", "dependencies": { "@smithy/protocol-http": "^5.3.12", @@ -4865,7 +4946,7 @@ "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-middleware": "^4.2.12", - "@smithy/util-stream": "^4.5.19", + "@smithy/util-stream": "^4.5.21", "@smithy/util-utf8": "^4.2.2", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" @@ -5082,13 +5163,13 @@ } }, "node_modules/@smithy/middleware-endpoint": { - "version": "4.4.25", - "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.25.tgz", - "integrity": "sha512-dqjLwZs2eBxIUG6Qtw8/YZ4DvzHGIf0DA18wrgtfP6a50UIO7e2nY0FPdcbv5tVJKqWCCU5BmGMOUwT7Puan+A==", + "version": "4.4.28", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.28.tgz", + "integrity": "sha512-p1gfYpi91CHcs5cBq982UlGlDrxoYUX6XdHSo91cQ2KFuz6QloHosO7Jc60pJiVmkWrKOV8kFYlGFFbQ2WUKKQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.23.11", - "@smithy/middleware-serde": "^4.2.14", + "@smithy/core": "^3.23.13", + "@smithy/middleware-serde": "^4.2.16", "@smithy/node-config-provider": "^4.3.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", @@ -5101,18 +5182,18 @@ } }, "node_modules/@smithy/middleware-retry": { - "version": "4.4.42", - "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.42.tgz", - "integrity": "sha512-vbwyqHRIpIZutNXZpLAozakzamcINaRCpEy1MYmK6xBeW3xN+TyPRA123GjXnuxZIjc9848MRRCugVMTXxC4Eg==", + "version": "4.4.46", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.46.tgz", + "integrity": "sha512-SpvWNNOPOrKQGUqZbEPO+es+FRXMWvIyzUKUOYdDgdlA6BdZj/R58p4umoQ76c2oJC44PiM7mKizyyex1IJzow==", "license": "Apache-2.0", "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/protocol-http": "^5.3.12", "@smithy/service-error-classification": "^4.2.12", - "@smithy/smithy-client": "^4.12.5", + "@smithy/smithy-client": "^4.12.8", "@smithy/types": "^4.13.1", "@smithy/util-middleware": "^4.2.12", - "@smithy/util-retry": "^4.2.12", + "@smithy/util-retry": "^4.2.13", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" }, @@ -5121,12 +5202,12 @@ } }, "node_modules/@smithy/middleware-serde": { - "version": "4.2.14", - "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.14.tgz", - "integrity": "sha512-+CcaLoLa5apzSRtloOyG7lQvkUw2ZDml3hRh4QiG9WyEPfW5Ke/3tPOPiPjUneuT59Tpn8+c3RVaUvvkkwqZwg==", + "version": "4.2.16", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.16.tgz", + "integrity": "sha512-beqfV+RZ9RSv+sQqor3xroUUYgRFCGRw6niGstPG8zO9LgTl0B0MCucxjmrH/2WwksQN7UUgI7KNANoZv+KALA==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.23.11", + "@smithy/core": "^3.23.13", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" @@ -5164,12 +5245,11 @@ } }, "node_modules/@smithy/node-http-handler": { - "version": "4.4.16", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.16.tgz", - "integrity": "sha512-ULC8UCS/HivdCB3jhi+kLFYe4B5gxH2gi9vHBfEIiRrT2jfKiZNiETJSlzRtE6B26XbBHjPtc8iZKSNqMol9bw==", + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.5.1.tgz", + "integrity": "sha512-ejjxdAXjkPIs9lyYyVutOGNOraqUE9v/NjGMKwwFrfOM354wfSD8lmlj8hVwUzQmlLLF4+udhfCX9Exnbmvfzw==", "license": "Apache-2.0", "dependencies": { - "@smithy/abort-controller": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/querystring-builder": "^4.2.12", "@smithy/types": "^4.13.1", @@ -5277,17 +5357,17 @@ } }, "node_modules/@smithy/smithy-client": { - "version": "4.12.5", - "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.5.tgz", - "integrity": "sha512-UqwYawyqSr/aog8mnLnfbPurS0gi4G7IYDcD28cUIBhsvWs1+rQcL2IwkUQ+QZ7dibaoRzhNF99fAQ9AUcO00w==", + "version": "4.12.8", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.8.tgz", + "integrity": "sha512-aJaAX7vHe5i66smoSSID7t4rKY08PbD8EBU7DOloixvhOozfYWdcSYE4l6/tjkZ0vBZhGjheWzB2mh31sLgCMA==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.23.11", - "@smithy/middleware-endpoint": "^4.4.25", + "@smithy/core": "^3.23.13", + "@smithy/middleware-endpoint": "^4.4.28", "@smithy/middleware-stack": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", - "@smithy/util-stream": "^4.5.19", + "@smithy/util-stream": "^4.5.21", "tslib": "^2.6.2" }, "engines": { @@ -5384,13 +5464,13 @@ } }, "node_modules/@smithy/util-defaults-mode-browser": { - "version": "4.3.41", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.41.tgz", - "integrity": "sha512-M1w1Ux0rSVvBOxIIiqbxvZvhnjQ+VUjJrugtORE90BbadSTH+jsQL279KRL3Hv0w69rE7EuYkV/4Lepz/NBW9g==", + "version": "4.3.44", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.44.tgz", + "integrity": "sha512-eZg6XzaCbVr2S5cAErU5eGBDaOVTuTo1I65i4tQcHENRcZ8rMWhQy1DaIYUSLyZjsfXvmCqZrstSMYyGFocvHA==", "license": "Apache-2.0", "dependencies": { "@smithy/property-provider": "^4.2.12", - "@smithy/smithy-client": "^4.12.5", + "@smithy/smithy-client": "^4.12.8", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, @@ -5399,16 +5479,16 @@ } }, "node_modules/@smithy/util-defaults-mode-node": { - "version": "4.2.44", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.44.tgz", - "integrity": "sha512-YPze3/lD1KmWuZsl9JlfhcgGLX7AXhSoaCDtiPntUjNW5/YY0lOHjkcgxyE9x/h5vvS1fzDifMGjzqnNlNiqOQ==", + "version": "4.2.48", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.48.tgz", + "integrity": "sha512-FqOKTlqSaoV3nzO55pMs5NBnZX8EhoI0DGmn9kbYeXWppgHD6dchyuj2HLqp4INJDJbSrj6OFYJkAh/WhSzZPg==", "license": "Apache-2.0", "dependencies": { - "@smithy/config-resolver": "^4.4.11", + "@smithy/config-resolver": "^4.4.13", "@smithy/credential-provider-imds": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/property-provider": "^4.2.12", - "@smithy/smithy-client": "^4.12.5", + "@smithy/smithy-client": "^4.12.8", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, @@ -5456,9 +5536,9 @@ } }, "node_modules/@smithy/util-retry": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.12.tgz", - "integrity": "sha512-1zopLDUEOwumjcHdJ1mwBHddubYF8GMQvstVCLC54Y46rqoHwlIU+8ZzUeaBcD+WCJHyDGSeZ2ml9YSe9aqcoQ==", + "version": "4.2.13", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.13.tgz", + "integrity": "sha512-qQQsIvL0MGIbUjeSrg0/VlQ3jGNKyM3/2iU3FPNgy01z+Sp4OvcaxbgIoFOTvB61ZoohtutuOvOcgmhbD0katQ==", "license": "Apache-2.0", "dependencies": { "@smithy/service-error-classification": "^4.2.12", @@ -5470,13 +5550,13 @@ } }, "node_modules/@smithy/util-stream": { - "version": "4.5.19", - "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.19.tgz", - "integrity": "sha512-v4sa+3xTweL1CLO2UP0p7tvIMH/Rq1X4KKOxd568mpe6LSLMQCnDHs4uv7m3ukpl3HvcN2JH6jiCS0SNRXKP/w==", + "version": "4.5.21", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.21.tgz", + "integrity": "sha512-KzSg+7KKywLnkoKejRtIBXDmwBfjGvg1U1i/etkC7XSWUyFCoLno1IohV2c74IzQqdhX5y3uE44r/8/wuK+A7Q==", "license": "Apache-2.0", "dependencies": { "@smithy/fetch-http-handler": "^5.3.15", - "@smithy/node-http-handler": "^4.4.16", + "@smithy/node-http-handler": "^4.5.1", "@smithy/types": "^4.13.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-buffer-from": "^4.2.2", @@ -5514,12 +5594,11 @@ } }, "node_modules/@smithy/util-waiter": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.2.13.tgz", - "integrity": "sha512-2zdZ9DTHngRtcYxJK1GUDxruNr53kv5W2Lupe0LMU+Imr6ohQg8M2T14MNkj1Y0wS3FFwpgpGQyvuaMF7CiTmQ==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.2.14.tgz", + "integrity": "sha512-2zqq5o/oizvMaFUlNiTyZ7dbgYv1a893aGut2uaxtbzTx/VYYnRxWzDHuD/ftgcw94ffenua+ZNLrbqwUYE+Bg==", "license": "Apache-2.0", "dependencies": { - "@smithy/abort-controller": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, @@ -5539,6 +5618,16 @@ "node": ">=18.0.0" } }, + "node_modules/@testcontainers/mysql": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@testcontainers/mysql/-/mysql-11.14.0.tgz", + "integrity": "sha512-0/OKd1gOvnl0qS+RIpmT6J0XKWpjkVyIbOtS3cFTVR8t6Ps7W9TV0U+zp7FPxCmitWuWYPXwH/+RQXF+1jZuZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "testcontainers": "^11.14.0" + } + }, "node_modules/@tsconfig/node10": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", @@ -5661,7 +5750,30 @@ "@types/node": "*" } }, - "node_modules/@types/esrecurse": { + "node_modules/@types/docker-modem": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/docker-modem/-/docker-modem-3.0.6.tgz", + "integrity": "sha512-yKpAGEuKRSS8wwx0joknWxsmLha78wNMe9R2S3UNsVOkZded8UqOrV8KoeDXoXsjndxwyF3eIhyClGbO1SEhEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/ssh2": "*" + } + }, + "node_modules/@types/dockerode": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@types/dockerode/-/dockerode-4.0.1.tgz", + "integrity": "sha512-cmUpB+dPN955PxBEuXE3f6lKO1hHiIGYJA46IVF3BJpNsZGvtBDcRnlrHYHtOH/B6vtDOyl2kZ2ShAu3mgc27Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/docker-modem": "*", + "@types/node": "*", + "@types/ssh2": "*" + } + }, + "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", @@ -5817,17 +5929,6 @@ "@types/express": "*" } }, - "node_modules/@types/pino": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/@types/pino/-/pino-7.0.5.tgz", - "integrity": "sha512-wKoab31pknvILkxAF8ss+v9iNyhw5Iu/0jLtRkUD74cNfOOLJNnqfFKAv0r7wVaTQxRZtWrMpGfShwwBjOcgcg==", - "deprecated": "This is a stub types definition. pino provides its own type definitions, so you do not need this installed.", - "dev": true, - "license": "MIT", - "dependencies": { - "pino": "*" - } - }, "node_modules/@types/qs": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", @@ -5863,6 +5964,43 @@ "@types/node": "*" } }, + "node_modules/@types/ssh2": { + "version": "1.15.5", + "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.5.tgz", + "integrity": "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "^18.11.18" + } + }, + "node_modules/@types/ssh2-streams": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/@types/ssh2-streams/-/ssh2-streams-0.1.13.tgz", + "integrity": "sha512-faHyY3brO9oLEA0QlcO8N2wT7R0+1sHWZvQ+y3rMLwdY1ZyS1z0W3t65j9PqT4HmQ6ALzNe7RZlNuCNE0wBSWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/ssh2/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/ssh2/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", @@ -5936,20 +6074,20 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.0.tgz", - "integrity": "sha512-qeu4rTHR3/IaFORbD16gmjq9+rEs9fGKdX0kF6BKSfi+gCuG3RCKLlSBYzn/bGsY9Tj7KE/DAQStbp8AHJGHEQ==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.0.tgz", + "integrity": "sha512-RLkVSiNuUP1C2ROIWfqX+YcUfLaSnxGE/8M+Y57lopVwg9VTYYfhuz15Yf1IzCKgZj6/rIbYTmJCUSqr76r0Wg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.57.0", - "@typescript-eslint/type-utils": "8.57.0", - "@typescript-eslint/utils": "8.57.0", - "@typescript-eslint/visitor-keys": "8.57.0", + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/type-utils": "8.58.0", + "@typescript-eslint/utils": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5959,9 +6097,9 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.57.0", + "@typescript-eslint/parser": "^8.58.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { @@ -5975,16 +6113,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.0.tgz", - "integrity": "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.0.tgz", + "integrity": "sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.57.0", - "@typescript-eslint/types": "8.57.0", - "@typescript-eslint/typescript-estree": "8.57.0", - "@typescript-eslint/visitor-keys": "8.57.0", + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", "debug": "^4.4.3" }, "engines": { @@ -5996,18 +6134,18 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.0.tgz", - "integrity": "sha512-pR+dK0BlxCLxtWfaKQWtYr7MhKmzqZxuii+ZjuFlZlIGRZm22HnXFqa2eY+90MUz8/i80YJmzFGDUsi8dMOV5w==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.0.tgz", + "integrity": "sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.57.0", - "@typescript-eslint/types": "^8.57.0", + "@typescript-eslint/tsconfig-utils": "^8.58.0", + "@typescript-eslint/types": "^8.58.0", "debug": "^4.4.3" }, "engines": { @@ -6018,18 +6156,18 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.0.tgz", - "integrity": "sha512-nvExQqAHF01lUM66MskSaZulpPL5pgy5hI5RfrxviLgzZVffB5yYzw27uK/ft8QnKXI2X0LBrHJFr1TaZtAibw==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.0.tgz", + "integrity": "sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.57.0", - "@typescript-eslint/visitor-keys": "8.57.0" + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6040,9 +6178,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.0.tgz", - "integrity": "sha512-LtXRihc5ytjJIQEH+xqjB0+YgsV4/tW35XKX3GTZHpWtcC8SPkT/d4tqdf1cKtesryHm2bgp6l555NYcT2NLvA==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.0.tgz", + "integrity": "sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A==", "dev": true, "license": "MIT", "engines": { @@ -6053,21 +6191,21 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.0.tgz", - "integrity": "sha512-yjgh7gmDcJ1+TcEg8x3uWQmn8ifvSupnPfjP21twPKrDP/pTHlEQgmKcitzF/rzPSmv7QjJ90vRpN4U+zoUjwQ==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.0.tgz", + "integrity": "sha512-aGsCQImkDIqMyx1u4PrVlbi/krmDsQUs4zAcCV6M7yPcPev+RqVlndsJy9kJ8TLihW9TZ0kbDAzctpLn5o+lOg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.57.0", - "@typescript-eslint/typescript-estree": "8.57.0", - "@typescript-eslint/utils": "8.57.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0", + "@typescript-eslint/utils": "8.58.0", "debug": "^4.4.3", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6078,13 +6216,13 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.0.tgz", - "integrity": "sha512-dTLI8PEXhjUC7B9Kre+u0XznO696BhXcTlOn0/6kf1fHaQW8+VjJAVHJ3eTI14ZapTxdkOmc80HblPQLaEeJdg==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.0.tgz", + "integrity": "sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==", "dev": true, "license": "MIT", "engines": { @@ -6096,21 +6234,21 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.0.tgz", - "integrity": "sha512-m7faHcyVg0BT3VdYTlX8GdJEM7COexXxS6KqGopxdtkQRvBanK377QDHr4W/vIPAR+ah9+B/RclSW5ldVniO1Q==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.0.tgz", + "integrity": "sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.57.0", - "@typescript-eslint/tsconfig-utils": "8.57.0", - "@typescript-eslint/types": "8.57.0", - "@typescript-eslint/visitor-keys": "8.57.0", + "@typescript-eslint/project-service": "8.58.0", + "@typescript-eslint/tsconfig-utils": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6120,7 +6258,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { @@ -6137,16 +6275,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.0.tgz", - "integrity": "sha512-5iIHvpD3CZe06riAsbNxxreP+MuYgVUsV0n4bwLH//VJmgtt54sQeY2GszntJ4BjYCpMzrfVh2SBnUQTtys2lQ==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.0.tgz", + "integrity": "sha512-RfeSqcFeHMHlAWzt4TBjWOAtoW9lnsAGiP3GbaX9uVgTYYrMbVnGONEfUCiSss+xMHFl+eHZiipmA8WkQ7FuNA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.57.0", - "@typescript-eslint/types": "8.57.0", - "@typescript-eslint/typescript-estree": "8.57.0" + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6157,17 +6295,17 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.0.tgz", - "integrity": "sha512-zm6xx8UT/Xy2oSr2ZXD0pZo7Jx2XsCoID2IUh9YSTFRu7z+WdwYTRk6LhUftm1crwqbuoF6I8zAFeCMw0YjwDg==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.0.tgz", + "integrity": "sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.57.0", + "@typescript-eslint/types": "8.58.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -6511,12 +6649,25 @@ } }, "node_modules/@xmldom/xmldom": { - "version": "0.8.11", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", - "integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==", + "version": "0.9.9", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.9.tgz", + "integrity": "sha512-qycIHAucxy/LXAYIjmLmtQ8q9GPnMbnjG1KXhWm9o5sCr6pOYDATkMPiTNa6/v8eELyqOQ2FsEqeoFYmgv/gJg==", "license": "MIT", "engines": { - "node": ">=10.0.0" + "node": ">=14.6" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dev": true, + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" } }, "node_modules/accepts": { @@ -6644,17 +6795,42 @@ "node": ">= 8" } }, - "node_modules/anymatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "node_modules/archiver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz", + "integrity": "sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==", "dev": true, "license": "MIT", + "dependencies": { + "archiver-utils": "^5.0.2", + "async": "^3.2.4", + "buffer-crc32": "^1.0.0", + "readable-stream": "^4.0.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^3.0.0", + "zip-stream": "^6.0.1" + }, "engines": { - "node": ">=8.6" + "node": ">= 14" + } + }, + "node_modules/archiver-utils": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-5.0.2.tgz", + "integrity": "sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob": "^10.0.0", + "graceful-fs": "^4.2.0", + "is-stream": "^2.0.1", + "lazystream": "^1.0.0", + "lodash": "^4.17.15", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "engines": { + "node": ">= 14" } }, "node_modules/arg": { @@ -6688,6 +6864,16 @@ "dev": true, "license": "MIT" }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, "node_modules/assert": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/assert/-/assert-2.1.0.tgz", @@ -6702,6 +6888,20 @@ "util": "^0.12.5" } }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-lock": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/async-lock/-/async-lock-1.4.1.tgz", + "integrity": "sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==", + "dev": true, + "license": "MIT" + }, "node_modules/async-retry": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", @@ -6770,6 +6970,21 @@ "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==", "license": "MIT" }, + "node_modules/b4a": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz", + "integrity": "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, "node_modules/babel-jest": { "version": "30.3.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.3.0.tgz", @@ -6879,10 +7094,128 @@ "node": "18 || 20 || >=22" } }, + "node_modules/bare-events": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", + "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.0.tgz", + "integrity": "sha512-xzqKsCFxAek9aezYhjJuJRXBIaYlg/0OGDTZp+T8eYmYMlm66cs6cYko02drIyjN2CBbi+I6L7YfXyqpqtKRXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-os": { + "version": "3.8.7", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.8.7.tgz", + "integrity": "sha512-G4Gr1UsGeEy2qtDTZwL7JFLo2wapUarz7iTMcYcMFdS89AIQuBoyjgXZz0Utv7uHs3xA9LckhVbeBi8lEQrC+w==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "bare": ">=1.14.0" + } + }, + "node_modules/bare-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", + "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-os": "^3.0.1" + } + }, + "node_modules/bare-stream": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.12.0.tgz", + "integrity": "sha512-w28i8lkBgREV3rPXGbgK+BO66q+ZpKqRWrZLiCdmmUlLPrQ45CzkvRhN+7lnv00Gpi2zy5naRxnUFAxCECDm9g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "streamx": "^2.25.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-abort-controller": "*", + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.0.tgz", + "integrity": "sha512-NSTU5WN+fy/L0DDenfE8SXQna4voXuW0FHM7wH8i3/q9khUSchfPbPezO4zSFMnDGIf9YE+mt/RWhZgNRKRIXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { - "version": "2.10.7", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.7.tgz", - "integrity": "sha512-1ghYO3HnxGec0TCGBXiDLVns4eCSx4zJpxnHrlqFQajmhfKMQBzUGDdkMK7fUW7PTHTeLf+j87aTuKuuwWzMGw==", + "version": "2.10.13", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.13.tgz", + "integrity": "sha512-BL2sTuHOdy0YT1lYieUxTw/QMtPBC3pmlJC6xk8BBYVv6vcw3SGdKemQ+Xsx9ik2F/lYDO9tqsFQH1r9PFuHKw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -6904,6 +7237,16 @@ "node": ">= 0.8" } }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, "node_modules/bcryptjs": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", @@ -6926,6 +7269,58 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bl/node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/bl/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/body-parser": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", @@ -6957,9 +7352,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6983,9 +7378,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", "dev": true, "funding": [ { @@ -7003,11 +7398,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" @@ -7039,6 +7434,41 @@ "node-int64": "^0.4.0" } }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer-crc32": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -7052,6 +7482,26 @@ "dev": true, "license": "MIT" }, + "node_modules/buildcheck": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz", + "integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==", + "dev": true, + "optional": true, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/byline": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/byline/-/byline-5.0.0.tgz", + "integrity": "sha512-s6webAy+R4SR8XVuJWt2V2rGvhnrhxN+9S15GNuTK3wKPOXFF6RNc+8ug2XhH+2s4f+uudG4kUVYmYOQWL2g0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -7140,9 +7590,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001778", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001778.tgz", - "integrity": "sha512-PN7uxFL+ExFJO61aVmP1aIEG4i9whQd4eoSCebav62UwDyp5OHh06zN4jqKSMePVgxHifCw1QJxdRkA1Pisekg==", + "version": "1.0.30001782", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001782.tgz", + "integrity": "sha512-dZcaJLJeDMh4rELYFw1tvSn1bhZWYFOt468FcbHHxx/Z/dFidd1I6ciyFdi3iwfQCyOjqo9upF6lGQYtMiJWxw==", "dev": true, "funding": [ { @@ -7295,6 +7745,13 @@ "node": ">= 6" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true, + "license": "ISC" + }, "node_modules/ci-info": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", @@ -7552,6 +8009,30 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/compress-commons": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz", + "integrity": "sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "crc32-stream": "^6.0.0", + "is-stream": "^2.0.1", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, "node_modules/constant-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/constant-case/-/constant-case-3.0.4.tgz", @@ -7628,6 +8109,13 @@ "dev": true, "license": "MIT" }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, "node_modules/cors": { "version": "2.8.6", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", @@ -7672,6 +8160,48 @@ } } }, + "node_modules/cpu-features": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", + "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "dependencies": { + "buildcheck": "~0.0.6", + "nan": "^2.19.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-6.0.0.tgz", + "integrity": "sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==", + "dev": true, + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", @@ -7874,51 +8404,173 @@ "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">=8" + } + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, + "node_modules/diff": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/docker-compose": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/docker-compose/-/docker-compose-1.4.2.tgz", + "integrity": "sha512-rPHigTKGaEHpkUmfd69QgaOp+Os5vGJwG/Ry8lcr8W/382AmI+z/D7qoa9BybKIkqNppaIbs8RYeHSevdQjWww==", + "dev": true, + "license": "MIT", + "dependencies": { + "yaml": "^2.2.2" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/docker-modem": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/docker-modem/-/docker-modem-5.0.7.tgz", + "integrity": "sha512-XJgGhoR/CLpqshm4d3L7rzH6t8NgDFUIIpztYlLHIApeJjMZKYJMz2zxPsYxnejq5h3ELYSw/RBsi3t5h7gNTA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.1", + "readable-stream": "^3.5.0", + "split-ca": "^1.0.1", + "ssh2": "^1.15.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/docker-modem/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/dockerode": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/dockerode/-/dockerode-4.0.10.tgz", + "integrity": "sha512-8L/P9JynLBiG7/coiA4FlQXegHltRqS0a+KqI44P1zgQh8QLHTg7FKOwhkBgSJwZTeHsq30WRoVFLuwkfK0YFg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@balena/dockerignore": "^1.0.2", + "@grpc/grpc-js": "^1.11.1", + "@grpc/proto-loader": "^0.7.13", + "docker-modem": "^5.0.7", + "protobufjs": "^7.3.2", + "tar-fs": "^2.1.4", + "uuid": "^10.0.0" + }, + "engines": { + "node": ">= 8.0" } }, - "node_modules/detect-newline": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", - "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "node_modules/dockerode/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "dev": true, "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, "engines": { - "node": ">=8" + "node": ">= 6" } }, - "node_modules/dezalgo": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", - "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "node_modules/dockerode/node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "asap": "^2.0.0", - "wrappy": "1" + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" } }, - "node_modules/diff": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", - "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "node_modules/dockerode/node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", "dev": true, - "license": "BSD-3-Clause", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, "engines": { - "node": ">=0.3.1" + "node": ">=6" } }, - "node_modules/dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "node_modules/dockerode/node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], "license": "MIT", - "dependencies": { - "path-type": "^4.0.0" - }, - "engines": { - "node": ">=8" + "bin": { + "uuid": "dist/bin/uuid" } }, "node_modules/dot-case": { @@ -7991,9 +8643,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.313", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.313.tgz", - "integrity": "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA==", + "version": "1.5.329", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.329.tgz", + "integrity": "sha512-/4t+AS1l4S3ZC0Ja7PHFIWeBIxGA3QGqV8/yKsP36v7NcyUCl+bIcmw6s5zVuMIECWwBrAK/6QLzTmbJChBboQ==", "dev": true, "license": "ISC" }, @@ -8026,6 +8678,16 @@ "node": ">= 0.8" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", @@ -8135,16 +8797,16 @@ } }, "node_modules/eslint": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.0.3.tgz", - "integrity": "sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.1.0.tgz", + "integrity": "sha512-S9jlY/ELKEUwwQnqWDO+f+m6sercqOPSqXM5Go94l7DOmxHVDgmSFGWEzeE/gwgTAr0W103BWt0QLe/7mabIvA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.3", - "@eslint/config-helpers": "^0.5.2", + "@eslint/config-helpers": "^0.5.3", "@eslint/core": "^1.1.1", "@eslint/plugin-kit": "^0.6.1", "@humanfs/node": "^0.16.6", @@ -8157,7 +8819,7 @@ "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", - "espree": "^11.1.1", + "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", @@ -8319,6 +8981,16 @@ "node": ">= 0.6" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/eventemitter3": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", @@ -8326,6 +8998,26 @@ "dev": true, "license": "MIT" }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -8464,6 +9156,13 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -8515,9 +9214,9 @@ "license": "MIT" }, "node_modules/fast-xml-builder": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.3.tgz", - "integrity": "sha512-1o60KoFw2+LWKQu3IdcfcFlGTW4dpqEWmjhYec6H82AYZU2TVBXep6tMl8Z1Y+wM+ZrzCwe3BZ9Vyd9N2rIvmg==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", + "integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==", "funding": [ { "type": "github", @@ -8530,9 +9229,9 @@ } }, "node_modules/fast-xml-parser": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.4.1.tgz", - "integrity": "sha512-BQ30U1mKkvXQXXkAGcuyUA/GA26oEB7NzOtsxCDtyu62sjGw5QraKFhx2Em3WQNjPw9PG6MQ9yuIIgkSDfGu5A==", + "version": "5.5.9", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.9.tgz", + "integrity": "sha512-jldvxr1MC6rtiZKgrFnDSvT8xuH+eJqxqOBThUVjYrxssYTo1avZLGql5l0a0BAERR01CadYzZ83kVEkbyDg+g==", "funding": [ { "type": "github", @@ -8541,8 +9240,9 @@ ], "license": "MIT", "dependencies": { - "fast-xml-builder": "^1.0.0", - "strnum": "^2.1.2" + "fast-xml-builder": "^1.1.4", + "path-expression-matcher": "^1.2.0", + "strnum": "^2.2.2" }, "bin": { "fxparser": "src/cli/cli.js" @@ -8699,9 +9399,9 @@ } }, "node_modules/flatted": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz", - "integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -8826,6 +9526,13 @@ "node": ">= 0.8" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, + "license": "MIT" + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -8943,6 +9650,19 @@ "node": ">=8.0.0" } }, + "node_modules/get-port": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/get-port/-/get-port-7.2.0.tgz", + "integrity": "sha512-afP4W205ONCuMoPBqcR6PSXnzX35KTcJygfJfcp+QY+uwm3p20p1YczWXhlICIzGMCxYBQcySEcOgsJcrkyobg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", @@ -9004,6 +9724,39 @@ "node": ">=10.13.0" } }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/globals": { "version": "17.4.0", "resolved": "https://registry.npmjs.org/globals/-/globals-17.4.0.tgz", @@ -9058,9 +9811,9 @@ "license": "ISC" }, "node_modules/graphql": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.1.tgz", - "integrity": "sha512-gGgrVCoDKlIZ8fIqXBBb0pPKqDgki0Z/FSKNiQzSGj2uEYHr1tq5wmBegGwJx6QB5S5cM0khSBpi/JFHMCvsmQ==", + "version": "16.13.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.2.tgz", + "integrity": "sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==", "license": "MIT", "engines": { "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" @@ -9141,9 +9894,9 @@ } }, "node_modules/graphql-ws": { - "version": "6.0.7", - "resolved": "https://registry.npmjs.org/graphql-ws/-/graphql-ws-6.0.7.tgz", - "integrity": "sha512-yoLRW+KRlDmnnROdAu7sX77VNLC0bsFoZyGQJLy1cF+X/SkLg/fWkRGrEEYQK8o2cafJ2wmEaMqMEZB3U3DYDg==", + "version": "6.0.8", + "resolved": "https://registry.npmjs.org/graphql-ws/-/graphql-ws-6.0.8.tgz", + "integrity": "sha512-m3EOaNsUBXwAnkBWbzPfe0Nq8pXUfxsWnolC54sru3FzHvhTZL0Ouf/BoQsaGAXqM+YPerXOJ47BUnmgmoupCw==", "dev": true, "license": "MIT", "engines": { @@ -9168,9 +9921,9 @@ } }, "node_modules/handlebars": { - "version": "4.7.8", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", - "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", "dev": true, "license": "MIT", "dependencies": { @@ -9351,6 +10104,27 @@ "url": "https://opencollective.com/express" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -9369,9 +10143,9 @@ "license": "ISC" }, "node_modules/immutable": { - "version": "4.3.8", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.8.tgz", - "integrity": "sha512-d/Ld9aLbKpNwyl0KiM2CT1WYvkitQ1TSvmRtkcV8FKStiDoA7Slzgjmb/1G2yhKM1p0XeNOieaTbFZmU1d3Xuw==", + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz", + "integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==", "dev": true, "license": "MIT" }, @@ -10748,6 +11522,52 @@ "@keyv/serialize": "^1.1.1" } }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -10870,9 +11690,16 @@ } }, "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", "dev": true, "license": "MIT" }, @@ -11291,19 +12118,6 @@ "node": ">=8.6" } }, - "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/mime": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", @@ -11366,13 +12180,13 @@ } }, "node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^5.0.5" }, "engines": { "node": "18 || 20 || >=22" @@ -11414,6 +12228,13 @@ "node": ">=10" } }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "dev": true, + "license": "MIT" + }, "node_modules/mnemonist": { "version": "0.38.3", "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.38.3.tgz", @@ -11450,9 +12271,9 @@ } }, "node_modules/mysql2": { - "version": "3.19.1", - "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.19.1.tgz", - "integrity": "sha512-yn4zh+Uxu5J3Zvi6Ao96lJ7BSBRkspHflWQAmOPND+htbpIKDQw99TTvPzgihKO/QyMickZopO4OsnixnpcUwA==", + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.20.0.tgz", + "integrity": "sha512-eCLUs7BNbgA6nf/MZXsaBO1SfGs0LtLVrJD3WeWq+jPLDWkSufTD+aGMwykfUVPdZnblaUK1a8G/P63cl9FkKg==", "license": "MIT", "dependencies": { "aws-ssl-profiles": "^1.1.2", @@ -11489,6 +12310,14 @@ "node": ">=8.0.0" } }, + "node_modules/nan": { + "version": "2.26.2", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.26.2.tgz", + "integrity": "sha512-0tTvBTYkt3tdGw22nrAy50x7gpbGCCFH3AFcyS5WiUu7Eu4vWlri1woE6qHBSfy11vksDqkiwjOnlR7WV8G1Hw==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/napi-postinstall": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", @@ -11610,9 +12439,9 @@ "license": "MIT" }, "node_modules/nodemailer": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.2.tgz", - "integrity": "sha512-zbj002pZAIkWQFxyAaqoxvn+zoIwRnS40hgjqTXudKOOJkiFFgBeNqjgD3/YCR12sZnrghWYBY+yP1ZucdDRpw==", + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.5.tgz", + "integrity": "sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==", "license": "MIT-0", "engines": { "node": ">=6.0.0" @@ -12001,9 +12830,9 @@ } }, "node_modules/path-expression-matcher": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.1.3.tgz", - "integrity": "sha512-qdVgY8KXmVdJZRSS1JdEPOKPdTiEK/pi0RkcT2sw1RhXxohdujUlJFPuS1TSkevZ9vzd3ZlL7ULl1MHGTApKzQ==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.2.0.tgz", + "integrity": "sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ==", "funding": [ { "type": "github", @@ -12090,9 +12919,9 @@ "license": "ISC" }, "node_modules/path-to-regexp": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", - "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.1.tgz", + "integrity": "sha512-fvU78fIjZ+SBM9YwCknCvKOUKkLVqtWDVctl0s7xIqfmfb38t2TT4ZU2gHm+Z8xGwgW+QWEU3oQSAzIbo89Ggw==", "license": "MIT", "funding": { "type": "opencollective", @@ -12117,9 +12946,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -12285,48 +13114,160 @@ "react-is": "^18.3.1" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/propagate": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz", + "integrity": "sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/proper-lockfile/node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/proper-lockfile/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/properties-reader": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/properties-reader/-/properties-reader-3.0.1.tgz", + "integrity": "sha512-WPn+h9RGEExOKdu4bsF4HksG/uzd3cFq3MFtq8PsFeExPse5Ha/VOjQNyHhjboBFwGXGev6muJYTSPAOkROq2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@kwsites/file-exists": "^1.1.1", + "mkdirp": "^3.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/steveukx/properties?sponsor=1" } }, - "node_modules/pretty-format/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "node_modules/properties-reader/node_modules/mkdirp": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", + "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", "dev": true, "license": "MIT", + "bin": { + "mkdirp": "dist/cjs/src/bin.js" + }, "engines": { "node": ">=10" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/process-warning": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", - "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, - "node_modules/propagate": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz", - "integrity": "sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==", + "node_modules/protobufjs": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", + "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", "dev": true, - "license": "MIT", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, "engines": { - "node": ">= 8" + "node": ">=12.0.0" } }, + "node_modules/protobufjs/node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -12347,6 +13288,17 @@ "dev": true, "license": "MIT" }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -12447,30 +13399,74 @@ "dev": true, "license": "MIT" }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", "dev": true, "license": "MIT", "dependencies": { - "picomatch": "^2.2.1" + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" }, "engines": { - "node": ">=8.10.0" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, - "node_modules/readdirp/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/readdir-glob/node_modules/brace-expansion": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", "dev": true, "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, "engines": { - "node": ">=8.6" + "node": ">=10" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "engines": { + "node": ">=8.10.0" } }, "node_modules/real-require": { @@ -12634,6 +13630,24 @@ "rimraf": "bin.js" } }, + "node_modules/rimraf/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/rimraf/node_modules/brace-expansion": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/rimraf/node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -12656,6 +13670,19 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/rimraf/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -13099,6 +14126,13 @@ "source-map": "^0.6.0" } }, + "node_modules/split-ca": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/split-ca/-/split-ca-1.0.1.tgz", + "integrity": "sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==", + "dev": true, + "license": "ISC" + }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -13140,6 +14174,46 @@ "url": "https://github.com/mysqljs/sql-escaper?sponsor=1" } }, + "node_modules/ssh-remote-port-forward": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/ssh-remote-port-forward/-/ssh-remote-port-forward-1.0.4.tgz", + "integrity": "sha512-x0LV1eVDwjf1gmG7TTnfqIzf+3VPRz7vrNIjX6oYLbeCrf/PeVY6hkT68Mg+q02qXxQhrLjB0jfgvhevoCRmLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ssh2": "^0.5.48", + "ssh2": "^1.4.0" + } + }, + "node_modules/ssh-remote-port-forward/node_modules/@types/ssh2": { + "version": "0.5.52", + "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-0.5.52.tgz", + "integrity": "sha512-lbLLlXxdCZOSJMCInKH2+9V/77ET2J6NPQHpFI0kda61Dd1KglJs+fPQBchizmzYSOJBgdTajhPqBO1xxLywvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/ssh2-streams": "*" + } + }, + "node_modules/ssh2": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz", + "integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "asn1": "^0.2.6", + "bcrypt-pbkdf": "^1.0.2" + }, + "engines": { + "node": ">=10.16.0" + }, + "optionalDependencies": { + "cpu-features": "~0.0.10", + "nan": "^2.23.0" + } + }, "node_modules/stack-utils": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", @@ -13172,6 +14246,18 @@ "node": ">= 0.8" } }, + "node_modules/streamx": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.25.0.tgz", + "integrity": "sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==", + "dev": true, + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, "node_modules/strict-event-emitter": { "version": "0.5.1", "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", @@ -13179,6 +14265,37 @@ "dev": true, "license": "MIT" }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/string-env-interpolation": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/string-env-interpolation/-/string-env-interpolation-1.0.1.tgz", @@ -13371,9 +14488,9 @@ } }, "node_modules/strnum": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.0.tgz", - "integrity": "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.2.tgz", + "integrity": "sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==", "funding": [ { "type": "github", @@ -13514,6 +14631,44 @@ "url": "https://opencollective.com/synckit" } }, + "node_modules/tar-fs": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.2.tgz", + "integrity": "sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" + } + }, + "node_modules/tar-stream": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.8.tgz", + "integrity": "sha512-U6QpVRyCGHva435KoNWy9PRoi2IFYCgtEhq9nmrPPpbRacPs9IH4aJ3gbrFC8dPcXvdSZ4XXfXT5Fshbp2MtlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "bare-fs": "^4.5.5", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -13529,6 +14684,24 @@ "node": ">=8" } }, + "node_modules/test-exclude/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/test-exclude/node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -13551,6 +14724,53 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/testcontainers": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/testcontainers/-/testcontainers-11.14.0.tgz", + "integrity": "sha512-r9pniwv/iwzyHaI7gwAvAm4Y+IvjJg3vBWdjrUCaDMc2AXIr4jKbq7jJO18Mw2ybs73pZy1Aj7p/4RVBGMRWjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@balena/dockerignore": "^1.0.2", + "@types/dockerode": "^4.0.1", + "archiver": "^7.0.1", + "async-lock": "^1.4.1", + "byline": "^5.0.0", + "debug": "^4.4.3", + "docker-compose": "^1.4.2", + "dockerode": "^4.0.10", + "get-port": "^7.2.0", + "proper-lockfile": "^4.1.2", + "properties-reader": "^3.0.1", + "ssh-remote-port-forward": "^1.0.4", + "tar-fs": "^3.1.2", + "tmp": "^0.2.5", + "undici": "^7.24.5" + } + }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, "node_modules/thread-stream": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", @@ -13600,6 +14820,16 @@ "tslib": "^2.0.3" } }, + "node_modules/tmp": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -13696,9 +14926,9 @@ "license": "MIT" }, "node_modules/ts-api-utils": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", - "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, "license": "MIT", "engines": { @@ -13912,6 +15142,13 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "dev": true, + "license": "Unlicense" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -13991,16 +15228,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.57.0.tgz", - "integrity": "sha512-W8GcigEMEeB07xEZol8oJ26rigm3+bfPHxHvwbYUlu1fUDsGuQ7Hiskx5xGW/xM4USc9Ephe3jtv7ZYPQntHeA==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.58.0.tgz", + "integrity": "sha512-e2TQzKfaI85fO+F3QywtX+tCTsu/D3WW5LVU6nz8hTFKFZ8yBJ6mSYRpXqdR3mFjPWmO0eWsTa5f+UpAOe/FMA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.57.0", - "@typescript-eslint/parser": "8.57.0", - "@typescript-eslint/typescript-estree": "8.57.0", - "@typescript-eslint/utils": "8.57.0" + "@typescript-eslint/eslint-plugin": "8.58.0", + "@typescript-eslint/parser": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0", + "@typescript-eslint/utils": "8.58.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -14011,7 +15248,7 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/uglify-js": { @@ -14045,6 +15282,16 @@ "dev": true, "license": "MIT" }, + "node_modules/undici": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.7.tgz", + "integrity": "sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", @@ -14203,6 +15450,13 @@ "which-typed-array": "^1.1.2" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, "node_modules/uuid": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", @@ -14517,9 +15771,9 @@ } }, "node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", "dev": true, "license": "MIT", "engines": { @@ -14575,9 +15829,9 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", "dev": true, "license": "ISC", "bin": { @@ -14710,6 +15964,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zip-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz", + "integrity": "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "archiver-utils": "^5.0.0", + "compress-commons": "^6.0.2", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/zod": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", diff --git a/package.json b/package.json index 7ce76324..07839a8c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "dmsp_apollo", - "version": "1.0.0", - "description": "Prototype backend for the new DMSP system", + "name": "dmptool-apollo-server", + "version": "1.1.0", + "description": "Apollo GraphQL server for the DMP Tool system", "main": "index.js", "scripts": { "compile": "tsc", @@ -36,20 +36,24 @@ "npm": ">=11.3.0" }, "overrides": { - "immutable": "4.3.8", - "minimatch": "10.2.4" + "fast-xml-parser": "5.5.9", + "flatted": "3.4.2", + "handlebars": "4.7.9", + "path-to-regexp": "8.4.1", + "picomatch": "4.0.4", + "lodash": "4.18.1" }, "dependencies": { "@apollo/datasource-rest": "^6.4.1", - "@apollo/server": "^5.4.0", + "@apollo/server": "^5.5.0", "@apollo/utils.keyvadapter": "^4.0.1", "@as-integrations/express5": "^1.1.2", - "@aws-sdk/client-dynamodb": "^3.1001.0", - "@aws-sdk/client-ssm": "^3.1001.0", - "@aws-sdk/credential-providers": "^3.1001.0", - "@aws-sdk/util-dynamodb": "^3.996.1", - "@dmptool/types": "^3.1.2", - "@dmptool/utils": "^1.0.41", + "@aws-sdk/client-dynamodb": "^3.1020.0", + "@aws-sdk/client-ssm": "^3.1020.0", + "@aws-sdk/credential-providers": "^3.1020.0", + "@aws-sdk/util-dynamodb": "^3.996.2", + "@dmptool/types": "^3.1.3", + "@dmptool/utils": "^1.0.43", "@elastic/ecs-pino-format": "^1.5.0", "@graphql-tools/merge": "^9.1.7", "@graphql-tools/mock": "^9.1.5", @@ -57,7 +61,7 @@ "@keyv/redis": "^5.1.6", "@node-oauth/express-oauth-server": "^4.1.5", "@opensearch-project/opensearch": "^3.5.1", - "@xmldom/xmldom": "^0.8.11", + "@xmldom/xmldom": "^0.9.9", "bcryptjs": "^3.0.3", "body-parser": "^2.2.2", "cookie-parser": "^1.4.7", @@ -65,14 +69,14 @@ "dotenv": "^17.3.1", "express": "^5.2.1", "express-jwt": "^8.5.1", - "graphql": "^16.13.0", + "graphql": "^16.13.2", "graphql-tag": "^2.12.6", "http-cache-semantics": "^4.2.0", "is-ci": "^4.1.0", "jsonwebtoken": "^9.0.3", "keyv": "^5.6.0", - "mysql2": "^3.18.2", - "nodemailer": "^8.0.1", + "mysql2": "^3.20.0", + "nodemailer": "^8.0.5", "pino": "^10.3.1", "uuid": "^9.0.1", "uuid-random": "^1.3.2", @@ -80,31 +84,31 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", - "@graphql-codegen/cli": "^6.1.3", + "@graphql-codegen/cli": "^6.2.1", "@graphql-codegen/typescript": "^5.0.9", "@graphql-codegen/typescript-resolvers": "^5.1.7", + "@testcontainers/mysql": "^11.13.0", "@types/cors": "^2.8.19", "@types/express": "^5.0.6", "@types/express-oauth-server": "^2.0.10", "@types/jest": "^30.0.0", "@types/oauth2-server": "^3.0.18", - "@types/pino": "^7.0.5", "@types/supertest": "^7.2.0", "assert": "^2.1.0", "casual": "^1.6.2", - "eslint": "^10.0.3", + "eslint": "^10.1.0", "eslint-formatter-unix": "^9.0.1", "globals": "^17.4.0", "husky": "^9.1.7", - "jest": "^30.2.0", + "jest": "^30.3.0", "jest-expect-message": "^1.1.3", - "jest-mock": "^30.2.0", + "jest-mock": "^30.3.0", "nock": "^14.0.11", "nodemon": "^3.1.14", "supertest": "^7.2.2", "ts-jest": "^29.4.6", "ts-node-dev": "^2.0.0", "typescript": "^5.9.3", - "typescript-eslint": "^8.56.1" + "typescript-eslint": "^8.58.0" } } diff --git a/renovate.json b/renovate.json index 3b7083be..b43aeb6c 100644 --- a/renovate.json +++ b/renovate.json @@ -12,6 +12,7 @@ "baseBranches": [ "development" ], + "rebaseWhen": "behind-base-branch", "ignorePaths": [ ".github", ".husky", diff --git a/src/controllers/ssoCallbackController.ts b/src/controllers/ssoCallbackController.ts index e2c7a156..7b32b8be 100644 --- a/src/controllers/ssoCallbackController.ts +++ b/src/controllers/ssoCallbackController.ts @@ -5,24 +5,19 @@ import { prepareObjectForLogs } from "../logger"; // This is the entry point for SSO response information from our Shibboleth SP export const ssoCallbackController = async (req: Request, res: Response) => { - -console.log('ssoCallbackController PATH', req.path); -console.log('ssoCallbackController QUERY', req.query); -console.log('ssoCallbackController BODY', req.body); -console.log('ssoCallbackController HEADERS', req.headers); -console.log('ssoCallbackController COOKIES', req.cookies); -console.log('ssoCallbackController PARAMS', req.params); -console.log('ssoCallbackController SIGNED COOKIES', req.signedCookies); - -const sessionId = req.cookies.find(cookie => cookie.name.startsWith('_shibsession_'))?.value; -console.log('ssoCallbackController PAYLOAD', { - uid: req.headers['uid'] || req.headers['remote-user'], - email: req.headers['mail'], - displayName: req.headers['displayname'], - sessionId -}); - - const { email, entityId } = req.body; + const samlPayload = { + uid: req.headers['x-shib-eppn'], + email: req.headers['x-shib-mail'], + affiliation: req.headers['x-shib-affiliation'], + displayName: req.headers['x-shib-displayname'], + givenName: req.headers['x-shib-givenname'], + surName: req.headers['x-shib-sn'], + sessionId: req.headers['x-shib-session-id'], + }; + + console.log('SAML PAYLOAD', samlPayload); + + // const { email, entityId } = req.body; const ref = 'ssoCallbackController'; const context = buildContext( @@ -53,10 +48,11 @@ console.log('ssoCallbackController PAYLOAD', { // If not, add the SSO JWT to the cookies as `dmps` // then redirect the user to the signup page - res.status(301).location(`/`).send(); + // res.status(301).location(`/`).send(); + res.status(200).json({ success: true, message: samlPayload }); } catch (err) { - context.logger.error(prepareObjectForLogs({ email, entityId, err }), 'SSO Passthrough 500 error'); + context.logger.error(prepareObjectForLogs({ samlPayload, err }), 'SSO Passthrough 500 error'); res.status(500).json({ success: false, message: 'Internal server error.' }); } } diff --git a/src/controllers/ssoPassthruController.ts b/src/controllers/ssoPassthruController.ts index 92f9554e..2706f449 100644 --- a/src/controllers/ssoPassthruController.ts +++ b/src/controllers/ssoPassthruController.ts @@ -8,7 +8,10 @@ import { Affiliation } from "../models/Affiliation"; // This is an endpoint that can be called to send a user to login to their // institutional SSO via our Shibboleth SP export const ssoPassthruController = async (req: Request, res: Response) => { - const { email, entityId } = req.body; + // const { email, entityId } = req.body; + const email = 'teser123@ucop.edu'; + const entityId = 'urn:mace:incommon:ucop.edu'; + const ref = 'ssoPassthruController'; const context = buildContext( diff --git a/src/datasources/__tests__/relatedWorksTables.spec.ts b/src/datasources/__tests__/relatedWorksTables.spec.ts index fc31dd41..f487da4e 100644 --- a/src/datasources/__tests__/relatedWorksTables.spec.ts +++ b/src/datasources/__tests__/relatedWorksTables.spec.ts @@ -1,15 +1,12 @@ +import fs from 'fs'; +import path from 'path'; import mysql, { Connection } from 'mysql2/promise'; -import { generalConfig } from '../../config/generalConfig'; -import { getParameter } from '../parameterStore'; -import { MyContext } from '../../context'; -import { buildContext } from '../../__mocks__/context'; -import { logger } from '../../logger'; +import { MySqlContainer, StartedMySqlContainer } from '@testcontainers/mysql'; import { Author, Award, ContentMatch, DoiMatch, Funder, Institution, ItemMatch } from '../../types'; import type { ResultSetHeader } from 'mysql2/promise'; interface RelatedWork { - planId: string | null | undefined; - dmpDoi: string | null | undefined; + planId: number; workDoi: string; hash: Buffer; sourceType: string; @@ -58,7 +55,18 @@ interface Project { modifiedById: number; } -export async function insertProjectAndPlan(connection: Connection, project: Project, plan: Plan) { +async function executeProceduresSql(conn: Connection, sql: string): Promise { + const cleaned = sql.replace(/DELIMITER\s+\$\$\s*/g, '').replace(/DELIMITER\s+;\s*/g, ''); + const statements = cleaned + .split('$$') + .map((s) => s.trim()) + .filter(Boolean); + for (const stmt of statements) { + await conn.query(stmt); + } +} + +async function insertProjectAndPlan(connection: Connection, project: Project, plan: Plan): Promise { await connection.beginTransaction(); try { @@ -72,7 +80,7 @@ export async function insertProjectAndPlan(connection: Connection, project: Proj const projectId = projectResult.insertId; - await connection.query( + const [planResult] = await connection.execute( ` INSERT INTO plans ( projectId, versionedTemplateId, visibility, status, dmpId, languageId, featured, createdById, modifiedById @@ -92,21 +100,21 @@ export async function insertProjectAndPlan(connection: Connection, project: Proj ); await connection.commit(); + return planResult.insertId; } catch (err) { await connection.rollback(); throw err; } } -export async function insertRelatedWorks(connection: Connection, data: RelatedWork[]) { +async function insertRelatedWorks(connection: Connection, data: RelatedWork[]) { if (data.length === 0) { return; } const sql = - 'INSERT INTO stagingRelatedWorks (planId, dmpDoi, workDoi, hash, sourceType, score, status, scoreMax, doiMatch, contentMatch, authorMatches, institutionMatches, funderMatches, awardMatches) VALUES ?'; + 'INSERT INTO stagingRelatedWorks (planId, workDoi, hash, sourceType, score, status, scoreMax, doiMatch, contentMatch, authorMatches, institutionMatches, funderMatches, awardMatches) VALUES ?'; const values = data.map((item) => [ item.planId, - item.dmpDoi, item.workDoi, item.hash, item.sourceType, @@ -124,7 +132,7 @@ export async function insertRelatedWorks(connection: Connection, data: RelatedWo await connection.query(sql, [values]); } -export async function insertWorkVersions(connection: Connection, data: WorkVersion[]) { +async function insertWorkVersions(connection: Connection, data: WorkVersion[]) { if (data.length === 0) { return; } @@ -149,134 +157,166 @@ export async function insertWorkVersions(connection: Connection, data: WorkVersi await connection.query(sql, [values]); } -let connection: mysql.Connection | null = null; -let context: MyContext; +const MIGRATIONS_DIR = path.join(__dirname, '../../../data-migrations'); -// Function to attempt to connect to the database in certain situations -async function tryGetConnection(context: MyContext) { - try { - let connection: Connection; - - // If we are running locally (test) or in the AWS development env (dev) - if (['dev', 'test'].includes(generalConfig.env)) { - if (generalConfig.env === 'test') { - // Running locally so use the Docker compose MySQL instance - connection = await mysql.createConnection({ - host: 'localhost', - port: 3306, - user: 'root', - password: 'd0ckerSecr3t', - database: 'dmptool', - multipleStatements: true, - }); - } else { - // Running in the AWS development environment so use the RDS instance - connection = await mysql.createConnection({ - host: await getParameter(context, '/uc3/dmp/tool/dev/RdsHost'), - port: Number(await getParameter(context, '/uc3/dmp/tool/dev/RdsPort')), - user: await getParameter(context, '/uc3/dmp/tool/dev/RdsUsername'), - password: await getParameter(context, '/uc3/dmp/tool/dev/RdsPassword'), - database: await getParameter(context, '/uc3/dmp/tool/dev/RdsName'), - multipleStatements: true, - }); - } - return connection; - } else { - // We are running in a different environment so skip the tests! - return null; - } - } catch (err) { - if (err.code === 'ECONNREFUSED' || err.code === 'ENOTFOUND' || err.code === 'EACCES' || err.code === 'ER_ACCESS_DENIED_ERROR') { - console.warn('MySQL is not running or access denied, skipping tests.'); - return null; - } - throw err; // unexpected error, let it bubble - } -} +let container: StartedMySqlContainer; +let connection: Connection; +let planAId: number; -const testPlanDOIs = ['https://doi.org/10.11111/2A3B4C']; -const testWorkDOIs = ['10.1234/fake-doi-001', '10.5678/sample.abc.2025']; +const testPlanDOIs: string[] = ['https://doi.org/10.11111/2A3B4C']; +const testWorkDOIs: string[] = ['10.1234/fake-doi-001', '10.5678/sample.abc.2025']; -function clearTestRecords() { - if (connection) { - const placeholders = testWorkDOIs.map(() => '?').join(', '); - connection.query( - ` - DELETE FROM relatedWorks - WHERE workVersionId IN ( - SELECT workVersions.id - FROM workVersions - INNER JOIN works ON workVersions.workId = works.id - WHERE works.doi IN (${placeholders}) - ); - `, - testWorkDOIs, - ); - - connection.query( - ` - DELETE FROM workVersions - WHERE workId IN (SELECT id FROM works WHERE doi IN (${placeholders})) - `, - testWorkDOIs, - ); - - connection.query( - ` - DELETE FROM works - WHERE doi IN (${placeholders}) - `, - testWorkDOIs, - ); +beforeAll(async () => { + container = await new MySqlContainer('mysql:8.0').withDatabase('dmptool').withRootPassword('test').start(); + + connection = await mysql.createConnection({ + host: container.getHost(), + port: container.getMappedPort(3306), + user: 'root', + password: 'test', + database: 'dmptool', + multipleStatements: true, + }); - connection.query( - ` - DELETE FROM plans - WHERE dmpId = '${testPlanDOIs[0]}' - `, - testWorkDOIs, - ); + // Apply all migrations in chronological order (filenames sort lexicographically by date) + const migrationFiles = fs + .readdirSync(MIGRATIONS_DIR) + .filter((f) => f.endsWith('.sql')) + .sort(); - connection.query( - ` - DELETE FROM projects - WHERE title = '${testPlanDOIs[0]}' - `, - testWorkDOIs, - ); + for (const file of migrationFiles) { + const sql = fs.readFileSync(path.join(MIGRATIONS_DIR, file), 'utf-8'); + if (sql.includes('DELIMITER')) { + await executeProceduresSql(connection, sql); + } else { + await connection.query(sql); + } } -} -beforeAll(async () => { - context = buildContext(logger); - connection = await tryGetConnection(context); - clearTestRecords(); -}); + // Seed minimal reference data for FK constraints + await connection.query(` + INSERT INTO users (id, password, role, givenName, surName) + VALUES (1, 'dummy', 'RESEARCHER', 'Test', 'User'); + `); + await connection.query(` + INSERT INTO affiliations (uri, name, displayName, createdById, modifiedById) + VALUES ('https://ror.org/test', 'Test University', 'Test University', 1, 1); + `); + await connection.query(` + INSERT INTO templates (id, name, ownerId, latestPublishVisibility, createdById, modifiedById) + VALUES (1, 'Test Template', 'https://ror.org/test', 'PRIVATE', 1, 1); + `); + await connection.query(` + INSERT INTO versionedTemplates (id, templateId, version, versionedById, name, ownerId, visibility, createdById, modifiedById) + VALUES (1, 1, '1.0', 1, 'Test Template v1', 'https://ror.org/test', 'PRIVATE', 1, 1); + `); +}, 120000); afterAll(async () => { - clearTestRecords(); if (connection) await connection.end(); + if (container) await container.stop(); }); +const makeDoiMatch = (score = 1.0): DoiMatch => ({ + found: true, + score, + sources: [{ awardId: 'ABC', awardUrl: 'https://url-of-funder/award-page' }], +}); + +const makeContentMatch = (score: number, titleHighlight: string): ContentMatch => ({ + score, + titleHighlight, + abstractHighlights: ['An abstract'], +}); + +const makeItemMatch = (index: number, score: number, fields: string[]): ItemMatch => ({ + index, + score, + fields, +}); + +const workVersion1: WorkVersion = { + doi: testWorkDOIs[0], + hash: Buffer.from('c4ca4238a0b923820dcc509a6f75849b', 'hex'), + workType: 'DATASET', + publicationDate: '2025-01-01', + title: 'Juvenile Eel Recruitment and Reef Nursery Conditions (JERRNC)', + abstractText: 'An abstract', + authors: [ + { + orcid: '0000-0003-1234-5678', + firstInitial: 'A', + givenName: 'Alyssa', + middleInitials: 'M', + middleNames: 'Marie', + surname: 'Langston', + full: null, + }, + ], + institutions: [{ name: 'University of California, Berkeley', ror: '01an7q238' }], + funders: [{ name: 'National Science Foundation', ror: '021nxhr62' }], + awards: [{ awardId: 'ABC' }], + publicationVenue: 'Zenodo', + sourceName: 'DataCite', + sourceUrl: `https://commons.datacite.org/doi.org/${testWorkDOIs[0]}`, +}; + +const workVersion2: WorkVersion = { + doi: testWorkDOIs[1], + hash: Buffer.from('c81e728d9d4c2f636f067f89cc14862c', 'hex'), + workType: 'ARTICLE', + publicationDate: '2025-02-01', + title: 'Climate Resilience of Eel-Reef Mutualisms: A Longitudinal Study', + abstractText: 'An abstract', + authors: [ + { + orcid: '0000-0003-1234-5678', + firstInitial: 'A', + givenName: 'Alyssa', + middleInitials: 'M', + middleNames: 'Marie', + surname: 'Langston', + full: null, + }, + { + orcid: null, + firstInitial: 'D', + givenName: 'David', + middleInitials: null, + middleNames: null, + surname: 'Choi', + full: null, + }, + ], + institutions: [{ name: 'University of California, Berkeley', ror: '01an7q238' }], + funders: [{ name: 'National Science Foundation', ror: '021nxhr62' }], + awards: [{ awardId: 'ABC' }], + publicationVenue: 'Nature', + sourceName: 'OpenAlex', + sourceUrl: 'https://openalex.org/works/W0000000001', +}; + +function makeRelatedWork(overrides: Partial & { planId: number; workDoi: string; hash: Buffer }): RelatedWork { + return { + sourceType: 'SYSTEM_MATCHED', + score: 1.0, + status: 'PENDING', + scoreMax: 1.0, + doiMatch: makeDoiMatch(), + contentMatch: makeContentMatch(18.0, 'Juvenile Eel Recruitment and Reef Nursery Conditions (JERRNC)'), + authorMatches: [makeItemMatch(0, 2.0, ['full', 'ror'])], + institutionMatches: [makeItemMatch(0, 2.0, ['name', 'ror'])], + funderMatches: [makeItemMatch(0, 1.0, ['name'])], + awardMatches: [{ index: 0, score: 10.0 } as ItemMatch], + ...overrides, + }; +} + describe('Related Works Tables', () => { - // Tests that the related works tables stored procedures insert, update and - // delete records correctly. test('1. should insert works', async () => { - // Inserts new related works, work versions and works and checks that - // they have been inserted correctly - if (!connection) { - console.warn('Skipping test: MySQL not available'); - return; - } - - await insertProjectAndPlan( + planAId = await insertProjectAndPlan( connection, - { - title: testPlanDOIs[0], - isTestProject: true, - createdById: 1, - modifiedById: 1, - }, + { title: testPlanDOIs[0], isTestProject: true, createdById: 1, modifiedById: 1 }, { versionedTemplateId: 1, visibility: 'PRIVATE', @@ -289,1247 +329,381 @@ describe('Related Works Tables', () => { }, ); - const workVersionsData = [ - { - doi: testWorkDOIs[0], - hash: Buffer.from('c4ca4238a0b923820dcc509a6f75849b', 'hex'), - workType: 'DATASET', - publicationDate: '2025-01-01', - title: 'Juvenile Eel Recruitment and Reef Nursery Conditions (JERRNC)', - abstractText: 'An abstract', - authors: [ - { - orcid: '0000-0003-1234-5678', - firstInitial: 'A', - givenName: 'Alyssa', - middleInitials: 'M', - middleNames: 'Marie', - surname: 'Langston', - full: null, - }, - ], - institutions: [ - { - name: 'University of California, Berkeley', - ror: '01an7q238', - }, - ], - funders: [{ name: 'National Science Foundation', ror: '021nxhr62' }], - awards: [{ awardId: 'ABC' }], - publicationVenue: 'Zenodo', - sourceName: 'DataCite', - sourceUrl: `https://commons.datacite.org/doi.org/${testWorkDOIs[0]}`, - }, - { - doi: testWorkDOIs[1], - hash: Buffer.from('c81e728d9d4c2f636f067f89cc14862c', 'hex'), - workType: 'ARTICLE', - publicationDate: '2025-02-01', - title: 'Climate Resilience of Eel-Reef Mutualisms: A Longitudinal Study', - abstractText: 'An abstract', - authors: [ - { - orcid: '0000-0003-1234-5678', - firstInitial: 'A', - givenName: 'Alyssa', - middleInitials: 'M', - middleNames: 'Marie', - surname: 'Langston', - full: null, - }, - { - orcid: null, - firstInitial: 'D', - givenName: 'David', - middleInitials: null, - middleNames: null, - surname: 'Choi', - full: null, - }, - ], - institutions: [ - { - name: 'University of California, Berkeley', - ror: '01an7q238', - }, - ], - funders: [{ name: 'National Science Foundation', ror: '021nxhr62' }], - awards: [{ awardId: 'ABC' }], - publicationVenue: 'Nature', - sourceName: 'OpenAlex', - sourceUrl: 'https://openalex.org/works/W0000000001', - }, - ]; - const relatedWorksData = [ - { - planId: null, - dmpDoi: '10.11111/2A3B4C', - workDoi: testWorkDOIs[0], - hash: Buffer.from('c4ca4238a0b923820dcc509a6f75849b', 'hex'), - sourceType: 'SYSTEM_MATCHED', - score: 1.0, - status: 'PENDING', - scoreMax: 1.0, - doiMatch: { - found: true, - score: 1.0, - sources: [ - { - awardId: 'ABC', - awardUrl: 'https://url-of-funder/award-page', - }, - ], - }, - contentMatch: { - score: 18.0, - titleHighlight: 'Juvenile Eel Recruitment and Reef Nursery Conditions (JERRNC)', - abstractHighlights: ['An abstract'], - }, - authorMatches: [ - { - index: 0, - score: 2.0, - fields: ['full', 'ror'], - }, - ], - institutionMatches: [ - { - index: 0, - score: 2.0, - fields: ['name', 'ror'], - }, - ], - funderMatches: [ - { - index: 0, - score: 1.0, - fields: ['name'], - }, - ], - awardMatches: [ - { - index: 0, - score: 10.0, - }, - ], - }, - { - planId: null, - dmpDoi: '10.11111/2A3B4C', + const relatedWorksData: RelatedWork[] = [ + makeRelatedWork({ planId: planAId, workDoi: testWorkDOIs[0], hash: workVersion1.hash }), + makeRelatedWork({ + planId: planAId, workDoi: testWorkDOIs[1], - hash: Buffer.from('c81e728d9d4c2f636f067f89cc14862c', 'hex'), - sourceType: 'SYSTEM_MATCHED', + hash: workVersion2.hash, score: 0.8, - status: 'PENDING', - scoreMax: 1.0, - doiMatch: { - found: true, - score: 1.0, - sources: [ - { - awardId: 'ABC', - awardUrl: 'https://url-of-funder/award-page', - }, - ], - }, - contentMatch: { - score: 18.0, - titleHighlight: 'Climate Resilience of Eel-Reef Mutualisms: A Longitudinal Study', - abstractHighlights: ['An abstract'], - }, - authorMatches: [ - { - index: 0, - score: 2.0, - fields: ['full', 'ror'], - }, - ], - institutionMatches: [ - { - index: 0, - score: 2.0, - fields: ['name', 'ror'], - }, - ], - funderMatches: [ - { - index: 0, - score: 1.0, - fields: ['name'], - }, - ], - awardMatches: [ - { - index: 0, - score: 10.0, - }, - ], - }, + contentMatch: makeContentMatch( + 18.0, + 'Climate Resilience of Eel-Reef Mutualisms: A Longitudinal Study', + ), + }), ]; + await connection.query('CALL create_related_works_staging_tables'); - await insertWorkVersions(connection, workVersionsData); + await insertWorkVersions(connection, [workVersion1, workVersion2]); await insertRelatedWorks(connection, relatedWorksData); - const systemMatched = true; - await connection.query("CALL batch_update_related_works(?)", [systemMatched]); + await connection.query('CALL batch_update_related_works(?)', [true]); + await connection.query('CALL cleanup_orphan_works'); - // Check relatedWorks table const [relatedWorksRows] = await connection.execute('SELECT * FROM relatedWorks'); expect(relatedWorksRows).toHaveLength(2); expect(relatedWorksRows).toMatchObject([ { - id: expect.any(Number), // 👈 auto-increment id - planId: expect.any(Number), // 👈 auto-increment id - workVersionId: expect.any(Number), // 👈 auto-increment id + id: expect.any(Number), + planId: expect.any(Number), + workVersionId: expect.any(Number), sourceType: 'SYSTEM_MATCHED', score: 1, scoreMax: 1.0, status: 'PENDING', - doiMatch: { - found: true, - score: 1.0, - sources: [ - { - awardId: 'ABC', - awardUrl: 'https://url-of-funder/award-page', - }, - ], - }, - contentMatch: { - score: 18.0, - titleHighlight: 'Juvenile Eel Recruitment and Reef Nursery Conditions (JERRNC)', - abstractHighlights: ['An abstract'], - }, - authorMatches: [ - { - index: 0, - score: 2.0, - fields: ['full', 'ror'], - }, - ], - institutionMatches: [ - { - index: 0, - score: 2.0, - fields: ['name', 'ror'], - }, - ], - funderMatches: [ - { - index: 0, - score: 1.0, - fields: ['name'], - }, - ], - awardMatches: [ - { - index: 0, - score: 10.0, - }, - ], - }, - { - id: expect.any(Number), // 👈 auto-increment id - planId: expect.any(Number), // 👈 auto-increment id - workVersionId: expect.any(Number), // 👈 auto-increment id + doiMatch: makeDoiMatch(), + contentMatch: makeContentMatch( + 18.0, + 'Juvenile Eel Recruitment and Reef Nursery Conditions (JERRNC)', + ), + authorMatches: [makeItemMatch(0, 2.0, ['full', 'ror'])], + institutionMatches: [makeItemMatch(0, 2.0, ['name', 'ror'])], + funderMatches: [makeItemMatch(0, 1.0, ['name'])], + awardMatches: [{ index: 0, score: 10.0 }], + }, + { + id: expect.any(Number), + planId: expect.any(Number), + workVersionId: expect.any(Number), sourceType: 'SYSTEM_MATCHED', score: expect.closeTo(0.8, 5), scoreMax: 1.0, status: 'PENDING', - doiMatch: { - found: true, - score: 1.0, - sources: [ - { - awardId: 'ABC', - awardUrl: 'https://url-of-funder/award-page', - }, - ], - }, - contentMatch: { - score: 18.0, - titleHighlight: 'Climate Resilience of Eel-Reef Mutualisms: A Longitudinal Study', - abstractHighlights: ['An abstract'], - }, - authorMatches: [ - { - index: 0, - score: 2.0, - fields: ['full', 'ror'], - }, - ], - institutionMatches: [ - { - index: 0, - score: 2.0, - fields: ['name', 'ror'], - }, - ], - funderMatches: [ - { - index: 0, - score: 1.0, - fields: ['name'], - }, - ], - awardMatches: [ - { - index: 0, - score: 10.0, - }, - ], + doiMatch: makeDoiMatch(), + contentMatch: makeContentMatch( + 18.0, + 'Climate Resilience of Eel-Reef Mutualisms: A Longitudinal Study', + ), + authorMatches: [makeItemMatch(0, 2.0, ['full', 'ror'])], + institutionMatches: [makeItemMatch(0, 2.0, ['name', 'ror'])], + funderMatches: [makeItemMatch(0, 1.0, ['name'])], + awardMatches: [{ index: 0, score: 10.0 }], }, ]); - // Check workVersions table const [workVersionsRows] = await connection.execute('SELECT * FROM workVersions'); expect(workVersionsRows).toHaveLength(2); expect(workVersionsRows).toMatchObject([ { - id: expect.any(Number), // 👈 auto-increment id - workId: expect.any(Number), // 👈 auto-increment id - hash: Buffer.from('c4ca4238a0b923820dcc509a6f75849b', 'hex'), + id: expect.any(Number), + workId: expect.any(Number), + hash: workVersion1.hash, workType: 'DATASET', publicationDate: expect.any(Date), - title: 'Juvenile Eel Recruitment and Reef Nursery Conditions (JERRNC)', - abstractText: 'An abstract', - authors: [ - { - orcid: '0000-0003-1234-5678', - firstInitial: 'A', - givenName: 'Alyssa', - middleInitials: 'M', - middleNames: 'Marie', - surname: 'Langston', - full: null, - }, - ], - institutions: [ - { - name: 'University of California, Berkeley', - ror: '01an7q238', - }, - ], - funders: [{ name: 'National Science Foundation', ror: '021nxhr62' }], - awards: [{ awardId: 'ABC' }], - publicationVenue: 'Zenodo', - sourceName: 'DataCite', - sourceUrl: `https://commons.datacite.org/doi.org/${testWorkDOIs[0]}`, - }, - { - id: expect.any(Number), // 👈 auto-increment id - workId: expect.any(Number), // 👈 auto-increment id - hash: Buffer.from('c81e728d9d4c2f636f067f89cc14862c', 'hex'), + title: workVersion1.title, + abstractText: workVersion1.abstractText, + authors: workVersion1.authors, + institutions: workVersion1.institutions, + funders: workVersion1.funders, + awards: workVersion1.awards, + publicationVenue: workVersion1.publicationVenue, + sourceName: workVersion1.sourceName, + sourceUrl: workVersion1.sourceUrl, + }, + { + id: expect.any(Number), + workId: expect.any(Number), + hash: workVersion2.hash, workType: 'ARTICLE', publicationDate: expect.any(Date), - title: 'Climate Resilience of Eel-Reef Mutualisms: A Longitudinal Study', - abstractText: 'An abstract', - authors: [ - { - orcid: '0000-0003-1234-5678', - firstInitial: 'A', - givenName: 'Alyssa', - middleInitials: 'M', - middleNames: 'Marie', - surname: 'Langston', - full: null, - }, - { - orcid: null, - firstInitial: 'D', - givenName: 'David', - middleInitials: null, - middleNames: null, - surname: 'Choi', - full: null, - }, - ], - institutions: [ - { - name: 'University of California, Berkeley', - ror: '01an7q238', - }, - ], - funders: [{ name: 'National Science Foundation', ror: '021nxhr62' }], - awards: [{ awardId: 'ABC' }], - publicationVenue: 'Nature', - sourceName: 'OpenAlex', - sourceUrl: 'https://openalex.org/works/W0000000001', + title: workVersion2.title, + abstractText: workVersion2.abstractText, + authors: workVersion2.authors, + institutions: workVersion2.institutions, + funders: workVersion2.funders, + awards: workVersion2.awards, + publicationVenue: workVersion2.publicationVenue, + sourceName: workVersion2.sourceName, + sourceUrl: workVersion2.sourceUrl, }, ]); - // Assert works table const [worksRows] = await connection.execute('SELECT * FROM works'); expect(worksRows).toHaveLength(2); expect(worksRows).toMatchObject([ - { - id: expect.any(Number), // 👈 auto-increment id - doi: testWorkDOIs[0], - created: expect.any(Date), - }, - { - id: expect.any(Number), // 👈 auto-increment id - doi: testWorkDOIs[1], - created: expect.any(Date), - }, + { id: expect.any(Number), doi: testWorkDOIs[0], created: expect.any(Date) }, + { id: expect.any(Number), doi: testWorkDOIs[1], created: expect.any(Date) }, ]); }); - test("2. should update works", async () => { - // Updates the second related work and links an updated work version to this - // related work, then checks that the data has been updated correctly. - if (!connection) { - console.warn("Skipping test: MySQL not available"); - return; - } - - const workVersionsData = [ - { - doi: testWorkDOIs[0], - hash: Buffer.from("c4ca4238a0b923820dcc509a6f75849b", "hex"), - workType: "DATASET", - publicationDate: "2025-01-01", - title: "Juvenile Eel Recruitment and Reef Nursery Conditions (JERRNC)", - abstractText: "An abstract", - authors: [ - { - orcid: "0000-0003-1234-5678", - firstInitial: "A", - givenName: "Alyssa", - middleInitials: "M", - middleNames: "Marie", - surname: "Langston", - full: null, - }, - ], - institutions: [ - { - name: "University of California, Berkeley", - ror: "01an7q238", - }, - ], - funders: [{ name: "National Science Foundation", ror: "021nxhr62" }], - awards: [{ awardId: "ABC" }], - publicationVenue: "Zenodo", - sourceName: "DataCite", - sourceUrl: `https://commons.datacite.org/doi.org/${testWorkDOIs[0]}`, - }, - { - doi: testWorkDOIs[1], - hash: Buffer.from("eccbc87e4b5ce2fe28308fd9f2a7baf3", "hex"), // Hash changed - workType: "DATASET", // Type changed - publicationDate: "2025-02-02", // Date changed - title: "Title: Climate Resilience of Eel-Reef Mutualisms: A Longitudinal Study", // Title changed - abstractText: "An abstract abstract", // Abstract changed - authors: [ - // Authors changed - { - orcid: "0000-0003-1234-5678", - firstInitial: "A", - givenName: "Alyssa", - middleInitials: "M", - middleNames: "Marie", - surname: "Langston", - full: null, - }, - { - orcid: null, - firstInitial: "D", - givenName: "Daniel", - middleInitials: null, - middleNames: null, - surname: "Choi", - full: null, - }, - ], - institutions: [ - // Institutions changed - { - name: "University of California", - ror: "01an7q238", - }, - ], - funders: [{ name: "National Science Foundation, USA", ror: "021nxhr62" }], // Funders changed - awards: [{ awardId: "ABC" }, { awardId: "123" }], // Award IDs changed - publicationVenue: "Nature Publications", // Publication venue changed - sourceName: "DataCite", // Source Changed - sourceUrl: `https://commons.datacite.org/doi.org/${testWorkDOIs[1]}`, // Source URL changed - }, - ]; - const relatedWorksData = [ - { - planId: null, - dmpDoi: "10.11111/2A3B4C", - workDoi: testWorkDOIs[0], - hash: Buffer.from("c4ca4238a0b923820dcc509a6f75849b", "hex"), - sourceType: "SYSTEM_MATCHED", - score: 1.0, - status: "PENDING", - scoreMax: 1.0, - doiMatch: { - found: true, - score: 1.0, - sources: [ - { - awardId: "ABC", - awardUrl: "https://url-of-funder/award-page", - }, - ], - }, - contentMatch: { - score: 18.0, - titleHighlight: "Juvenile Eel Recruitment and Reef Nursery Conditions (JERRNC)", - abstractHighlights: ["An abstract"], - }, - authorMatches: [ - { - index: 0, - score: 2.0, - fields: ["full", "ror"], - }, - ], - institutionMatches: [ - { - index: 0, - score: 2.0, - fields: ["name", "ror"], - }, - ], - funderMatches: [ - { - index: 0, - score: 1.0, - fields: ["name"], - }, - ], - awardMatches: [ - { - index: 0, - score: 10.0, - }, - ], - }, - { - planId: null, - dmpDoi: "10.11111/2A3B4C", + test('2. should update works', async () => { + const updatedWorkVersion2: WorkVersion = { + ...workVersion2, + hash: Buffer.from('eccbc87e4b5ce2fe28308fd9f2a7baf3', 'hex'), + workType: 'DATASET', + publicationDate: '2025-02-02', + title: 'Title: Climate Resilience of Eel-Reef Mutualisms: A Longitudinal Study', + abstractText: 'An abstract abstract', + authors: [...workVersion2.authors.slice(0, 1), { ...workVersion2.authors[1] ?? {}, givenName: 'Daniel' }], + institutions: [{ name: 'University of California', ror: '01an7q238' }], + funders: [{ name: 'National Science Foundation, USA', ror: '021nxhr62' }], + awards: [{ awardId: 'ABC' }, { awardId: '123' }], + publicationVenue: 'Nature Publications', + sourceName: 'DataCite', + sourceUrl: `https://commons.datacite.org/doi.org/${testWorkDOIs[1]}`, + }; + + const relatedWorksData: RelatedWork[] = [ + makeRelatedWork({ planId: planAId, workDoi: testWorkDOIs[0], hash: workVersion1.hash }), + makeRelatedWork({ + planId: planAId, workDoi: testWorkDOIs[1], - hash: Buffer.from("eccbc87e4b5ce2fe28308fd9f2a7baf3", "hex"), // Hash changed - sourceType: "SYSTEM_MATCHED", - score: 0.9, // Score changed - status: "PENDING", - scoreMax: 1.0, - doiMatch: { - // doiMatch changed - found: true, - score: 2.0, - sources: [ - { - awardId: "ABC", - awardUrl: "https://url-of-funder/award-page", - }, - ], - }, - contentMatch: { - // contentMatch changed - score: 20.0, - titleHighlight: "Climate Resilience of Eel-Reef Mutualisms: A Longitudinal Study", - abstractHighlights: ["An abstract"], - }, - authorMatches: [ - // authorMatches changed - { - index: 1, - score: 2.0, - fields: ["full", "ror"], - }, - ], - institutionMatches: [ - // institutionMatches changed - { - index: 1, - score: 2.0, - fields: ["name", "ror"], - }, - ], - funderMatches: [ - // funderMatches changed - { - index: 1, - score: 1.0, - fields: ["name"], - }, - ], - awardMatches: [ - // awardMatches changed - { - index: 1, - score: 10.0, - }, - ], - }, + hash: updatedWorkVersion2.hash, + score: 0.9, + doiMatch: makeDoiMatch(2.0), + contentMatch: makeContentMatch( + 20.0, + 'Climate Resilience of Eel-Reef Mutualisms: A Longitudinal Study', + ), + authorMatches: [makeItemMatch(1, 2.0, ['full', 'ror'])], + institutionMatches: [makeItemMatch(1, 2.0, ['name', 'ror'])], + funderMatches: [makeItemMatch(1, 1.0, ['name'])], + awardMatches: [{ index: 1, score: 10.0 } as ItemMatch], + }), ]; - await connection.query("CALL create_related_works_staging_tables"); - await insertWorkVersions(connection, workVersionsData); + + await connection.query('CALL create_related_works_staging_tables'); + await insertWorkVersions(connection, [workVersion1, updatedWorkVersion2]); await insertRelatedWorks(connection, relatedWorksData); - const systemMatched = true; - await connection.query("CALL batch_update_related_works(?)", [systemMatched]); + await connection.query('CALL batch_update_related_works(?)', [true]); + await connection.query('CALL cleanup_orphan_works'); - // Check relatedWorks table - const [relatedWorksRows] = await connection.execute("SELECT * FROM relatedWorks"); + const [relatedWorksRows] = await connection.execute('SELECT * FROM relatedWorks'); expect(relatedWorksRows).toHaveLength(2); expect(relatedWorksRows).toMatchObject([ { - id: expect.any(Number), // 👈 auto-increment id - planId: expect.any(Number), // 👈 auto-increment id - workVersionId: expect.any(Number), // 👈 auto-increment id - sourceType: "SYSTEM_MATCHED", + sourceType: 'SYSTEM_MATCHED', score: 1, scoreMax: 1.0, - status: "PENDING", - doiMatch: { - found: true, - score: 1.0, - sources: [ - { - awardId: "ABC", - awardUrl: "https://url-of-funder/award-page", - }, - ], - }, - contentMatch: { - score: 18.0, - titleHighlight: "Juvenile Eel Recruitment and Reef Nursery Conditions (JERRNC)", - abstractHighlights: ["An abstract"], - }, - authorMatches: [ - { - index: 0, - score: 2.0, - fields: ["full", "ror"], - }, - ], - institutionMatches: [ - { - index: 0, - score: 2.0, - fields: ["name", "ror"], - }, - ], - funderMatches: [ - { - index: 0, - score: 1.0, - fields: ["name"], - }, - ], - awardMatches: [ - { - index: 0, - score: 10.0, - }, - ], + status: 'PENDING', }, { - id: expect.any(Number), // 👈 auto-increment id - planId: expect.any(Number), // 👈 auto-increment id - workVersionId: expect.any(Number), // 👈 auto-increment id - sourceType: "SYSTEM_MATCHED", + sourceType: 'SYSTEM_MATCHED', score: expect.closeTo(0.9, 5), scoreMax: 1.0, - status: "PENDING", - doiMatch: { - found: true, - score: 2.0, - sources: [ - { - awardId: "ABC", - awardUrl: "https://url-of-funder/award-page", - }, - ], - }, - contentMatch: { - score: 20.0, - titleHighlight: "Climate Resilience of Eel-Reef Mutualisms: A Longitudinal Study", - abstractHighlights: ["An abstract"], - }, - authorMatches: [ - { - index: 1, - score: 2.0, - fields: ["full", "ror"], - }, - ], - institutionMatches: [ - { - index: 1, - score: 2.0, - fields: ["name", "ror"], - }, - ], - funderMatches: [ - { - index: 1, - score: 1.0, - fields: ["name"], - }, - ], - awardMatches: [ - { - index: 1, - score: 10.0, - }, - ], + status: 'PENDING', + doiMatch: makeDoiMatch(2.0), + contentMatch: makeContentMatch( + 20.0, + 'Climate Resilience of Eel-Reef Mutualisms: A Longitudinal Study', + ), + authorMatches: [makeItemMatch(1, 2.0, ['full', 'ror'])], + institutionMatches: [makeItemMatch(1, 2.0, ['name', 'ror'])], + funderMatches: [makeItemMatch(1, 1.0, ['name'])], + awardMatches: [{ index: 1, score: 10.0 }], }, ]); - // Check workVersions table - const [workVersionsRows] = await connection.execute("SELECT * FROM workVersions"); + const [workVersionsRows] = await connection.execute('SELECT * FROM workVersions'); expect(workVersionsRows).toHaveLength(2); expect(workVersionsRows).toMatchObject([ + { hash: workVersion1.hash, workType: 'DATASET', title: workVersion1.title }, { - id: expect.any(Number), // 👈 auto-increment id - workId: expect.any(Number), // 👈 auto-increment id - hash: Buffer.from("c4ca4238a0b923820dcc509a6f75849b", "hex"), - workType: "DATASET", - publicationDate: expect.any(Date), - title: "Juvenile Eel Recruitment and Reef Nursery Conditions (JERRNC)", - abstractText: "An abstract", - authors: [ - { - orcid: "0000-0003-1234-5678", - firstInitial: "A", - givenName: "Alyssa", - middleInitials: "M", - middleNames: "Marie", - surname: "Langston", - full: null, - }, - ], - institutions: [ - { - name: "University of California, Berkeley", - ror: "01an7q238", - }, - ], - funders: [{ name: "National Science Foundation", ror: "021nxhr62" }], - awards: [{ awardId: "ABC" }], - publicationVenue: "Zenodo", - sourceName: "DataCite", - sourceUrl: `https://commons.datacite.org/doi.org/${testWorkDOIs[0]}`, - }, - { - id: expect.any(Number), // 👈 auto-increment id - workId: expect.any(Number), // 👈 auto-increment id - hash: Buffer.from("eccbc87e4b5ce2fe28308fd9f2a7baf3", "hex"), - workType: "DATASET", - publicationDate: expect.any(Date), - title: "Title: Climate Resilience of Eel-Reef Mutualisms: A Longitudinal Study", - abstractText: "An abstract abstract", - authors: [ - { - orcid: "0000-0003-1234-5678", - firstInitial: "A", - givenName: "Alyssa", - middleInitials: "M", - middleNames: "Marie", - surname: "Langston", - full: null, - }, - { - orcid: null, - firstInitial: "D", - givenName: "Daniel", - middleInitials: null, - middleNames: null, - surname: "Choi", - full: null, - }, - ], - institutions: [ - { - name: "University of California", - ror: "01an7q238", - }, - ], - funders: [{ name: "National Science Foundation, USA", ror: "021nxhr62" }], - awards: [{ awardId: "ABC" }, { awardId: "123" }], - publicationVenue: "Nature Publications", - sourceName: "DataCite", - sourceUrl: `https://commons.datacite.org/doi.org/${testWorkDOIs[1]}`, + hash: updatedWorkVersion2.hash, + workType: 'DATASET', + title: updatedWorkVersion2.title, + abstractText: updatedWorkVersion2.abstractText, + authors: updatedWorkVersion2.authors, + institutions: updatedWorkVersion2.institutions, + funders: updatedWorkVersion2.funders, + awards: updatedWorkVersion2.awards, + publicationVenue: updatedWorkVersion2.publicationVenue, + sourceName: updatedWorkVersion2.sourceName, + sourceUrl: updatedWorkVersion2.sourceUrl, }, ]); - // Assert works table - const [worksRows] = await connection.execute("SELECT * FROM works"); + const [worksRows] = await connection.execute('SELECT * FROM works'); expect(worksRows).toHaveLength(2); expect(worksRows).toMatchObject([ - { - id: expect.any(Number), // 👈 auto-increment id - doi: testWorkDOIs[0], - created: expect.any(Date), - }, - { - id: expect.any(Number), // 👈 auto-increment id - doi: testWorkDOIs[1], - created: expect.any(Date), - }, + { doi: testWorkDOIs[0], created: expect.any(Date) }, + { doi: testWorkDOIs[1], created: expect.any(Date) }, ]); }); - test("3. should keep accepted and rejected works", async () => { - // Updates the status of the two related works to accepted and rejected - // and then runs procedures with empty data, which tests that accepted and - // rejected related works, work version and works are not deleted. - if (!connection) { - console.warn("Skipping test: MySQL not available"); - return; - } - - // Accept work + test('3. should keep accepted and rejected works', async () => { + // Accept first work, reject second await connection.query(` UPDATE relatedWorks SET status = 'ACCEPTED' WHERE workVersionId = ( - SELECT workVersions.id - FROM workVersions - INNER JOIN works ON works.id = workVersions.workId - WHERE works.doi = '${testWorkDOIs[0]}' + SELECT wv.id FROM workVersions wv + INNER JOIN works w ON w.id = wv.workId + WHERE w.doi = '${testWorkDOIs[0]}' + LIMIT 1 ); `); - - // Reject work await connection.query(` UPDATE relatedWorks SET status = 'REJECTED' WHERE workVersionId = ( - SELECT workVersions.id - FROM workVersions - INNER JOIN works ON works.id = workVersions.workId - WHERE works.doi = '${testWorkDOIs[1]}' + SELECT wv.id FROM workVersions wv + INNER JOIN works w ON w.id = wv.workId + WHERE w.doi = '${testWorkDOIs[1]}' + LIMIT 1 ); `); - // Load empty data, which would delete any unlinked pending results - // but should keep accepted and rejected works - await connection.query("CALL create_related_works_staging_tables"); + // Load empty staging data — should not delete accepted/rejected works + await connection.query('CALL create_related_works_staging_tables'); await insertWorkVersions(connection, []); await insertRelatedWorks(connection, []); - const systemMatched = true; - await connection.query("CALL batch_update_related_works(?)", [systemMatched]); + await connection.query('CALL batch_update_related_works(?)', [true]); + await connection.query('CALL cleanup_orphan_works'); - // Check relatedWorks table - const [relatedWorksRows] = await connection.execute("SELECT * FROM relatedWorks"); + const [relatedWorksRows] = await connection.execute('SELECT * FROM relatedWorks'); expect(relatedWorksRows).toHaveLength(2); - expect(relatedWorksRows).toMatchObject([ - { - id: expect.any(Number), // 👈 auto-increment id - planId: expect.any(Number), // 👈 auto-increment id - workVersionId: expect.any(Number), // 👈 auto-increment id - sourceType: "SYSTEM_MATCHED", - score: 1, - scoreMax: 1.0, - status: "ACCEPTED", - doiMatch: { - found: true, - score: 1.0, - sources: [ - { - awardId: "ABC", - awardUrl: "https://url-of-funder/award-page", - }, - ], - }, - contentMatch: { - score: 18.0, - titleHighlight: "Juvenile Eel Recruitment and Reef Nursery Conditions (JERRNC)", - abstractHighlights: ["An abstract"], - }, - authorMatches: [ - { - index: 0, - score: 2.0, - fields: ["full", "ror"], - }, - ], - institutionMatches: [ - { - index: 0, - score: 2.0, - fields: ["name", "ror"], - }, - ], - funderMatches: [ - { - index: 0, - score: 1.0, - fields: ["name"], - }, - ], - awardMatches: [ - { - index: 0, - score: 10.0, - }, - ], - }, - { - id: expect.any(Number), // 👈 auto-increment id - planId: expect.any(Number), // 👈 auto-increment id - workVersionId: expect.any(Number), // 👈 auto-increment id - sourceType: "SYSTEM_MATCHED", - score: expect.closeTo(0.9, 5), - scoreMax: 1.0, - status: "REJECTED", - doiMatch: { - found: true, - score: 2.0, - sources: [ - { - awardId: "ABC", - awardUrl: "https://url-of-funder/award-page", - }, - ], - }, - contentMatch: { - score: 20.0, - titleHighlight: "Climate Resilience of Eel-Reef Mutualisms: A Longitudinal Study", - abstractHighlights: ["An abstract"], - }, - authorMatches: [ - { - index: 1, - score: 2.0, - fields: ["full", "ror"], - }, - ], - institutionMatches: [ - { - index: 1, - score: 2.0, - fields: ["name", "ror"], - }, - ], - funderMatches: [ - { - index: 1, - score: 1.0, - fields: ["name"], - }, - ], - awardMatches: [ - { - index: 1, - score: 10.0, - }, - ], - }, - ]); + expect(relatedWorksRows).toMatchObject([{ status: 'ACCEPTED' }, { status: 'REJECTED' }]); - // Check workVersions table - const [workVersionsRows] = await connection.execute("SELECT * FROM workVersions"); + const [workVersionsRows] = await connection.execute('SELECT * FROM workVersions'); expect(workVersionsRows).toHaveLength(2); - expect(workVersionsRows).toMatchObject([ - { - id: expect.any(Number), // 👈 auto-increment id - workId: expect.any(Number), // 👈 auto-increment id - hash: Buffer.from("c4ca4238a0b923820dcc509a6f75849b", "hex"), - workType: "DATASET", - publicationDate: expect.any(Date), - title: "Juvenile Eel Recruitment and Reef Nursery Conditions (JERRNC)", - abstractText: "An abstract", - authors: [ - { - orcid: "0000-0003-1234-5678", - firstInitial: "A", - givenName: "Alyssa", - middleInitials: "M", - middleNames: "Marie", - surname: "Langston", - full: null, - }, - ], - institutions: [ - { - name: "University of California, Berkeley", - ror: "01an7q238", - }, - ], - funders: [{ name: "National Science Foundation", ror: "021nxhr62" }], - awards: [{ awardId: "ABC" }], - publicationVenue: "Zenodo", - sourceName: "DataCite", - sourceUrl: `https://commons.datacite.org/doi.org/${testWorkDOIs[0]}`, - }, - { - id: expect.any(Number), // 👈 auto-increment id - workId: expect.any(Number), // 👈 auto-increment id - hash: Buffer.from("eccbc87e4b5ce2fe28308fd9f2a7baf3", "hex"), - workType: "DATASET", - publicationDate: expect.any(Date), - title: "Title: Climate Resilience of Eel-Reef Mutualisms: A Longitudinal Study", - abstractText: "An abstract abstract", - authors: [ - { - orcid: "0000-0003-1234-5678", - firstInitial: "A", - givenName: "Alyssa", - middleInitials: "M", - middleNames: "Marie", - surname: "Langston", - full: null, - }, - { - orcid: null, - firstInitial: "D", - givenName: "Daniel", - middleInitials: null, - middleNames: null, - surname: "Choi", - full: null, - }, - ], - institutions: [ - { - name: "University of California", - ror: "01an7q238", - }, - ], - funders: [{ name: "National Science Foundation, USA", ror: "021nxhr62" }], - awards: [{ awardId: "ABC" }, { awardId: "123" }], - publicationVenue: "Nature Publications", - sourceName: "DataCite", - sourceUrl: `https://commons.datacite.org/doi.org/${testWorkDOIs[1]}`, - }, - ]); - // Assert works table - const [worksRows] = await connection.execute("SELECT * FROM works"); + const [worksRows] = await connection.execute('SELECT * FROM works'); expect(worksRows).toHaveLength(2); - expect(worksRows).toMatchObject([ - { - id: expect.any(Number), // 👈 auto-increment id - doi: testWorkDOIs[0], - created: expect.any(Date), - }, - { - id: expect.any(Number), // 👈 auto-increment id - doi: testWorkDOIs[1], - created: expect.any(Date), - }, - ]); }); - test("4. should delete unlinked pending related works", async () => { - // Checks that unlinked pending related works and their work versions and works - // are deleted. Sets all works to pending, then makes an update where only - // one related work is in staging table, all other pending related works, work - // versions and works should be deleted. - if (!connection) { - console.warn("Skipping test: MySQL not available"); - return; - } + test('4. should delete unlinked pending related works', async () => { + // Set all works back to pending + await connection.query(`UPDATE relatedWorks SET status = 'PENDING'`); - // Set work to pending - await connection.query( - `UPDATE relatedWorks - SET status = 'PENDING'`, - ); + // Only keep one work in staging + const relatedWorksData: RelatedWork[] = [makeRelatedWork({ planId: planAId, workDoi: testWorkDOIs[0], hash: workVersion1.hash })]; - // Only keep one work - const workVersionsData = [ - { - doi: testWorkDOIs[0], - hash: Buffer.from("c4ca4238a0b923820dcc509a6f75849b", "hex"), - workType: "DATASET", - publicationDate: "2025-01-01", - title: "Juvenile Eel Recruitment and Reef Nursery Conditions (JERRNC)", - abstractText: "An abstract", - authors: [ - { - orcid: "0000-0003-1234-5678", - firstInitial: "A", - givenName: "Alyssa", - middleInitials: "M", - middleNames: "Marie", - surname: "Langston", - full: null, - }, - ], - institutions: [ - { - name: "University of California, Berkeley", - ror: "01an7q238", - }, - ], - funders: [{ name: "National Science Foundation", ror: "021nxhr62" }], - awards: [{ awardId: "ABC" }], - publicationVenue: "Zenodo", - sourceName: "DataCite", - sourceUrl: `https://commons.datacite.org/doi.org/${testWorkDOIs[0]}`, - }, - ]; - const relatedWorksData = [ - { - planId: null, - dmpDoi: "10.11111/2A3B4C", - workDoi: testWorkDOIs[0], - hash: Buffer.from("c4ca4238a0b923820dcc509a6f75849b", "hex"), - sourceType: "SYSTEM_MATCHED", - score: 1.0, - status: "PENDING", - scoreMax: 1.0, - doiMatch: { - found: true, - score: 1.0, - sources: [ - { - awardId: "ABC", - awardUrl: "https://url-of-funder/award-page", - }, - ], - }, - contentMatch: { - score: 18.0, - titleHighlight: "Juvenile Eel Recruitment and Reef Nursery Conditions (JERRNC)", - abstractHighlights: ["An abstract"], - }, - authorMatches: [ - { - index: 0, - score: 2.0, - fields: ["full", "ror"], - }, - ], - institutionMatches: [ - { - index: 0, - score: 2.0, - fields: ["name", "ror"], - }, - ], - funderMatches: [ - { - index: 0, - score: 1.0, - fields: ["name"], - }, - ], - awardMatches: [ - { - index: 0, - score: 10.0, - }, - ], - }, - ]; - await connection.query("CALL create_related_works_staging_tables"); - await insertWorkVersions(connection, workVersionsData); + await connection.query('CALL create_related_works_staging_tables'); + await insertWorkVersions(connection, [workVersion1]); await insertRelatedWorks(connection, relatedWorksData); - const systemMatched = true; - await connection.query("CALL batch_update_related_works(?)", [systemMatched]); + await connection.query('CALL batch_update_related_works(?)', [true]); + await connection.query('CALL cleanup_orphan_works'); - // Check relatedWorks table - const [relatedWorksRows] = await connection.execute("SELECT * FROM relatedWorks"); + const [relatedWorksRows] = await connection.execute('SELECT * FROM relatedWorks'); expect(relatedWorksRows).toHaveLength(1); - expect(relatedWorksRows).toMatchObject([ - { - id: expect.any(Number), // 👈 auto-increment id - planId: expect.any(Number), // 👈 auto-increment id - workVersionId: expect.any(Number), // 👈 auto-increment id - sourceType: "SYSTEM_MATCHED", - score: 1, - scoreMax: 1.0, - status: "PENDING", - doiMatch: { - found: true, - score: 1.0, - sources: [ - { - awardId: "ABC", - awardUrl: "https://url-of-funder/award-page", - }, - ], - }, - contentMatch: { - score: 18.0, - titleHighlight: "Juvenile Eel Recruitment and Reef Nursery Conditions (JERRNC)", - abstractHighlights: ["An abstract"], - }, - authorMatches: [ - { - index: 0, - score: 2.0, - fields: ["full", "ror"], - }, - ], - institutionMatches: [ - { - index: 0, - score: 2.0, - fields: ["name", "ror"], - }, - ], - funderMatches: [ - { - index: 0, - score: 1.0, - fields: ["name"], - }, - ], - awardMatches: [ - { - index: 0, - score: 10.0, - }, - ], - }, - ]); + expect(relatedWorksRows).toMatchObject([{ sourceType: 'SYSTEM_MATCHED', score: 1, status: 'PENDING' }]); - // Check workVersions table - const [workVersionsRows] = await connection.execute("SELECT * FROM workVersions"); + const [workVersionsRows] = await connection.execute('SELECT * FROM workVersions'); expect(workVersionsRows).toHaveLength(1); expect(workVersionsRows).toMatchObject([ - { - id: expect.any(Number), // 👈 auto-increment id - workId: expect.any(Number), // 👈 auto-increment id - hash: Buffer.from("c4ca4238a0b923820dcc509a6f75849b", "hex"), - workType: "DATASET", - publicationDate: expect.any(Date), - title: "Juvenile Eel Recruitment and Reef Nursery Conditions (JERRNC)", - abstractText: "An abstract", - authors: [ - { - orcid: "0000-0003-1234-5678", - firstInitial: "A", - givenName: "Alyssa", - middleInitials: "M", - middleNames: "Marie", - surname: "Langston", - full: null, - }, - ], - institutions: [ - { - name: "University of California, Berkeley", - ror: "01an7q238", - }, - ], - funders: [{ name: "National Science Foundation", ror: "021nxhr62" }], - awards: [{ awardId: "ABC" }], - publicationVenue: "Zenodo", - sourceName: "DataCite", - sourceUrl: `https://commons.datacite.org/doi.org/${testWorkDOIs[0]}`, - }, + { hash: workVersion1.hash, workType: 'DATASET', title: workVersion1.title }, ]); - // Assert works table - const [worksRows] = await connection.execute("SELECT * FROM works"); + const [worksRows] = await connection.execute('SELECT * FROM works'); expect(worksRows).toHaveLength(1); - expect(worksRows).toMatchObject([ + expect(worksRows).toMatchObject([{ doi: testWorkDOIs[0] }]); + }); + + test('5. should not delete other plans pending works when batching per-DMP', async () => { + // Setup: Create Plan B with its own work + const planBDoi = 'https://doi.org/10.22222/PLAN_B'; + const workDoi3 = '10.9999/third-work-doi'; + + const planBId = await insertProjectAndPlan( + connection, + { title: planBDoi, isTestProject: true, createdById: 1, modifiedById: 1 }, { - id: expect.any(Number), // 👈 auto-increment id - doi: testWorkDOIs[0], - created: expect.any(Date), + versionedTemplateId: 1, + visibility: 'PRIVATE', + status: 'DRAFT', + dmpId: planBDoi, + languageId: 'en-US', + featured: 0, + createdById: 1, + modifiedById: 1, }, + ); + + const workVersion3: WorkVersion = { + ...workVersion1, + doi: workDoi3, + hash: Buffer.from('a87ff679a2f3e71d9181a67b7542122c', 'hex'), + title: 'Third Work for Plan B', + }; + + // Add a second work for Plan A and a work for Plan B (include both Plan A works in staging) + await connection.query('CALL create_related_works_staging_tables'); + await insertWorkVersions(connection, [workVersion1, workVersion2, workVersion3]); + await insertRelatedWorks(connection, [ + makeRelatedWork({ planId: planAId, workDoi: testWorkDOIs[0], hash: workVersion1.hash }), + makeRelatedWork({ planId: planAId, workDoi: testWorkDOIs[1], hash: workVersion2.hash }), + makeRelatedWork({ planId: planBId, workDoi: workDoi3, hash: workVersion3.hash }), + ]); + await connection.query('CALL batch_update_related_works(?)', [true]); + await connection.query('CALL cleanup_orphan_works'); + + // Verify: Plan A has 2 works (one from test 4, one just added), Plan B has 1 + const [beforeRows] = await connection.execute( + 'SELECT r.*, p.dmpId FROM relatedWorks r JOIN plans p ON r.planId = p.id ORDER BY p.dmpId, r.id', + ); + const planABefore = beforeRows.filter((r: ResultSetHeader) => r['dmpId'] === testPlanDOIs[0]); + const planBBefore = beforeRows.filter((r: ResultSetHeader) => r['dmpId'] === planBDoi); + expect(planABefore).toHaveLength(2); + expect(planBBefore).toHaveLength(1); + + // Now batch for Plan A only with just 1 of its 2 works + await connection.query('CALL create_related_works_staging_tables'); + await insertWorkVersions(connection, [workVersion1]); + await insertRelatedWorks(connection, [makeRelatedWork({ planId: planAId, workDoi: testWorkDOIs[0], hash: workVersion1.hash })]); + await connection.query('CALL batch_update_related_works(?)', [true]); + + // Plan A: stale pending work deleted, only the staged one remains + // Plan B: untouched — its PENDING work must still be there + const [afterRows] = await connection.execute( + 'SELECT r.*, p.dmpId FROM relatedWorks r JOIN plans p ON r.planId = p.id ORDER BY p.dmpId, r.id', + ); + const planAAfter = afterRows.filter((r: ResultSetHeader) => r['dmpId'] === testPlanDOIs[0]); + const planBAfter = afterRows.filter((r: ResultSetHeader) => r['dmpId'] === planBDoi); + + expect(planAAfter).toHaveLength(1); + expect(planBAfter).toHaveLength(1); + }); + + test('6. systemMatched=false should only update status, not delete or update metadata', async () => { + // State from test 5: Plan A has 1 PENDING work (testWorkDOIs[0]), Plan B has 1 PENDING work (workDoi3) + const planBDoi = 'https://doi.org/10.22222/PLAN_B'; + + // Capture current scores before the update + const [beforeRows] = await connection.execute( + 'SELECT r.score, r.scoreMax, r.sourceType, p.dmpId FROM relatedWorks r JOIN plans p ON r.planId = p.id', + ); + const planABefore = beforeRows.find((r: ResultSetHeader) => r['dmpId'] === testPlanDOIs[0]); + + // Stage Plan A's work with status=ACCEPTED and different score/sourceType + await connection.query('CALL create_related_works_staging_tables'); + await insertWorkVersions(connection, [workVersion1]); + await insertRelatedWorks(connection, [ + makeRelatedWork({ + planId: planAId, + workDoi: testWorkDOIs[0], + hash: workVersion1.hash, + status: 'ACCEPTED', + sourceType: 'USER_ADDED', + score: 999, + }), ]); + await connection.query('CALL batch_update_related_works(?)', [false]); + + const [afterRows] = await connection.execute( + 'SELECT r.score, r.sourceType, r.status, p.dmpId FROM relatedWorks r JOIN plans p ON r.planId = p.id ORDER BY p.dmpId', + ); + const planAAfter:ResultSetHeader = afterRows.find((r: ResultSetHeader) => r['dmpId'] === testPlanDOIs[0]); + const planBAfter:ResultSetHeader = afterRows.find((r: ResultSetHeader) => r['dmpId'] === planBDoi); + + // Status should be updated + expect(planAAfter['status']).toBe('ACCEPTED'); + + // Score and sourceType should NOT be updated (systemMatched=false only touches status) + expect(planAAfter['score']).toBe(planABefore['score']); + expect(planAAfter['sourceType']).toBe(planABefore['sourceType']); + + // Plan B's PENDING work should NOT be deleted (no PENDING deletion in systemMatched=false path) + expect(planBAfter).toBeDefined(); + expect(planBAfter['status']).toBe('PENDING'); }); }); diff --git a/src/models/AffiliationEmailDomain.ts b/src/models/AffiliationEmailDomain.ts index dc174973..46bff78d 100644 --- a/src/models/AffiliationEmailDomain.ts +++ b/src/models/AffiliationEmailDomain.ts @@ -4,7 +4,7 @@ import { MySqlModel } from "./MySqlModel"; // An email domain associated with this affiliation. For use with SSO export class AffiliationEmailDomain extends MySqlModel { public affiliationId!: string; - public domain!: string; + public emailDomain!: string; private tableName = 'affiliationEmailDomains'; @@ -12,7 +12,7 @@ export class AffiliationEmailDomain extends MySqlModel { super(options.id, options.created, options.createdById, options.modified, options.modifiedById, options.errors); this.affiliationId = options.affiliationId - this.domain = options.domain; + this.emailDomain = options.emailDomain; } // Validation to be used prior to saving the record @@ -20,7 +20,7 @@ export class AffiliationEmailDomain extends MySqlModel { await super.isValid(); if (!this.affiliationId) this.addError('affiliationId', 'Affiliation can\'t be blank'); - if (!this.domain) this.addError('domain', 'Domain can\'t be blank'); + if (!this.emailDomain) this.addError('emailDomain', 'Domain can\'t be blank'); return Object.keys(this.errors).length === 0; } @@ -31,7 +31,7 @@ export class AffiliationEmailDomain extends MySqlModel { const currentDomain = await AffiliationEmailDomain.findByDomain( 'AffiliationEmailDomain.create', context, - this.domain, + this.emailDomain, ); if (await this.isValid()) { @@ -68,7 +68,7 @@ export class AffiliationEmailDomain extends MySqlModel { // Search by the domain static async findByDomain(reference: string, context: MyContext, domain: string): Promise { - const sql = `SELECT * FROM affiliationEmailDomains WHERE domain LIKE ?`; + const sql = `SELECT * FROM affiliationEmailDomains WHERE emailDomain LIKE ?`; const results = await AffiliationEmailDomain.query(context, sql, [domain], reference); return Array.isArray(results) && results.length > 0 ? new AffiliationEmailDomain(results[0]) : null; } diff --git a/src/models/Answer.ts b/src/models/Answer.ts index 79fe167c..20c8849a 100644 --- a/src/models/Answer.ts +++ b/src/models/Answer.ts @@ -2,14 +2,23 @@ import { MyContext } from "../context"; import { MySqlModel } from "./MySqlModel"; import { isNullOrUndefined, - removeNullAndUndefinedFromJSON + removeNullAndUndefinedFromJSON, } from "../utils/helpers"; -import { AnswerSchemaMap } from "@dmptool/types"; +import { + AnswerSchemaMap, + AnyResearchOutputTableColumnAnswerType, + ResearchOutputTableRowAnswerType, + CURRENT_SCHEMA_VERSION, + QuestionFormatsEnum, + SelectBoxAnswerType +} from "@dmptool/types"; export class Answer extends MySqlModel { public planId: number; public versionedSectionId: number; public versionedQuestionId: number; + public versionedCustomSectionId?: number; + public versionedCustomQuestionId?: number; public json: string; private static tableName = 'answers'; @@ -20,6 +29,8 @@ export class Answer extends MySqlModel { this.planId = options.planId; this.versionedSectionId = options.versionedSectionId; this.versionedQuestionId = options.versionedQuestionId; + this.versionedCustomSectionId = options.versionedCustomSectionId; + this.versionedCustomQuestionId = options.versionedCustomQuestionId; this.json = options.json; // Ensure json is stored as a string try { @@ -32,10 +43,19 @@ export class Answer extends MySqlModel { // Validation to be used prior to saving the record async isValid(): Promise { await super.isValid(); + const hasBase = !!this.versionedSectionId && !!this.versionedQuestionId; + const hasCustomInCustomSection = !!this.versionedCustomSectionId && !!this.versionedCustomQuestionId; + const hasCustomInBaseSection = !!this.versionedSectionId && !!this.versionedCustomQuestionId; + + // three valid combinations: 1) base section and question, 2) custom section and custom question, 3) base section and custom question (for when a custom question is added to a base section) + if (!hasBase && !hasCustomInCustomSection && !hasCustomInBaseSection) { + this.addError('general', 'Answer must belong to either a base or custom section and question'); + } + if (hasBase && hasCustomInCustomSection) { + this.addError('general', 'Answer cannot belong to both a base and custom section simultaneously'); + } if (!this.planId) this.addError('planId', 'Plan can\'t be blank'); - if (!this.versionedSectionId) this.addError('versionedSectionId', 'Section can\'t be blank'); - if (!this.versionedQuestionId) this.addError('versionedQuestionId', 'Question can\'t be blank'); // If json is not null or undefined and the type is in the schema map if (!isNullOrUndefined(this.json) && this.errors['json'] === undefined) { @@ -68,20 +88,70 @@ export class Answer extends MySqlModel { prepForSave(): void { // Remove leading/trailing blank spaces this.json = this.json?.trim(); + + const parsedJSON = JSON.parse(this.json); + + // If this is not a research output table, we don't need to do anything further + if (parsedJSON.type !== QuestionFormatsEnum.enum.researchOutputTable) { + return; + } + + // Extract the output type columns + let modified = false; + + // Process each output in the table + parsedJSON.answer = parsedJSON.answer.map((row: ResearchOutputTableRowAnswerType) => { + const columns: AnyResearchOutputTableColumnAnswerType[] = row.columns || []; + const typeCol = columns.find((col: AnyResearchOutputTableColumnAnswerType) => { + return col.commonStandardId === 'type'; + }) as SelectBoxAnswerType; + + // If the output type column is missing or empty/null + if (!typeCol || !typeCol.answer || typeCol.answer.trim() === '') { + modified = true; + + // Create the default column object + const defaultTypeCol: SelectBoxAnswerType = { + type: "selectBox", + commonStandardId: 'type', + answer: "Unknown", + meta: { schemaVersion: CURRENT_SCHEMA_VERSION }, + } as unknown as SelectBoxAnswerType; + + // Return the row with the updated columns list + return { + ...row, + columns: [ + ...columns.filter((col: AnyResearchOutputTableColumnAnswerType) => { + return col.commonStandardId !== 'type'; + }), + defaultTypeCol + ] + }; + } + + return row; + }); + + // Only stringify and re-assign if we actually changed something + if (modified) { + this.json = JSON.stringify(parsedJSON); + } } //Create a new Answer async create(context: MyContext): Promise { const reference = 'Answer.create'; + this.prepForSave(); + // First make sure the record is valid if (await this.isValid()) { - const current = await Answer.findByPlanIdAndVersionedQuestionId( - reference, - context, - this.planId, - this.versionedQuestionId - ); + // Check if an answer already exists for this planId and versionedQuestionId or versionedCustomQuestionId (depending on which one is being used) + const current = this.versionedQuestionId + ? await Answer.findByPlanIdAndVersionedQuestionId(reference, context, this.planId, this.versionedQuestionId) + : await Answer.findByPlanIdAndVersionedCustomQuestionId(reference, context, this.planId, this.versionedCustomQuestionId); + // Then make sure it doesn't already exist if (current) { @@ -89,8 +159,12 @@ export class Answer extends MySqlModel { } else { // Save the record and then fetch it const newId = await Answer.insert(context, Answer.tableName, this, reference); - const response = await Answer.findById(reference, context, newId); - return response; + if (!newId) { + this.addError('general', 'Failed to save answer'); + } else { + const response = await Answer.findById(reference, context, newId); + return response; + } } } // Otherwise return as-is with all the errors @@ -99,6 +173,8 @@ export class Answer extends MySqlModel { //Update an existing Answer async update(context: MyContext, noTouch = false): Promise { + this.prepForSave(); + if (await this.isValid()) { if (this.id) { await Answer.update(context, Answer.tableName, this, 'Answer.update', [], noTouch); @@ -149,6 +225,18 @@ export class Answer extends MySqlModel { return Array.isArray(results) && results.length > 0 ? new Answer(results[0]) : null; } + static async findByPlanIdAndVersionedCustomQuestionId( + reference: string, + context: MyContext, + planId: number, + versionedCustomQuestionId: number + ): Promise { + const sql = `SELECT * FROM answers WHERE planId = ? AND versionedCustomQuestionId = ?`; + const results = await Answer.query(context, sql, [planId.toString(), versionedCustomQuestionId.toString()], reference); + return Array.isArray(results) && results.length > 0 ? new Answer(results[0]) : null; + } + + // Fetch all of the answers for a versionedSectionId static async findByPlanIdAndVersionedSectionId( reference: string, @@ -182,4 +270,22 @@ export class Answer extends MySqlModel { const results = await Answer.query(context, sql, [String(planId), ...questionIds.map(String)], reference); return Array.isArray(results) && results.length > 0 ? results.map((ans) => new Answer(ans)) : []; } + + // Given a list of question ids, return all filled answers for custom questions + static async findFilledAnswersByCustomQuestionIds( + reference: string, + context: MyContext, + planId: number, + customQuestionIds: number[] + ): Promise { + if (!Array.isArray(customQuestionIds) || customQuestionIds.length === 0) return []; + + const placeholders = customQuestionIds.map(() => '?').join(', '); + const sql = `SELECT * FROM answers + WHERE planId = ? + AND versionedCustomQuestionId IN (${placeholders}) + AND json IS NOT NULL AND json != ''`; + const results = await Answer.query(context, sql, [String(planId), ...customQuestionIds.map(String)], reference); + return Array.isArray(results) && results.length > 0 ? results.map((ans) => new Answer(ans)) : []; + } }; diff --git a/src/models/CustomQuestion.ts b/src/models/CustomQuestion.ts index 4892e277..8f8eb98a 100644 --- a/src/models/CustomQuestion.ts +++ b/src/models/CustomQuestion.ts @@ -5,7 +5,7 @@ import { removeNullAndUndefinedFromJSON } from "../utils/helpers"; import { MyContext } from "../context"; -import {DefaultTextAreaQuestion, QuestionSchemaMap} from "@dmptool/types"; +import { DefaultTextAreaQuestion, QuestionSchemaMap } from "@dmptool/types"; import { PinnedSectionTypeEnum } from "./CustomSection"; /** @@ -324,4 +324,104 @@ export class CustomQuestion extends MySqlModel { ); return Array.isArray(results) && results.length > 0 ? new CustomQuestion(results[0]) : undefined; } + + /** + * Find all custom questions for a specific section within a template customization + * + * @param reference The reference to use for logging errors. + * @param context The Apollo context. + * @param templateCustomizationId The id of the template customization. + * @param sectionId The id of the section (either a versionedSection id or customSection id). + * @returns The custom questions. + */ + static async findByCustomizationAndSectionId( + reference: string, + context: MyContext, + templateCustomizationId: number, + sectionType: PinnedSectionTypeEnum, + sectionId: number + ): Promise { + const results = await CustomQuestion.query( + context, + `SELECT * FROM ${CustomQuestion.tableName} + WHERE templateCustomizationId = ? AND sectionType = ? AND sectionId = ?`, + [templateCustomizationId.toString(), sectionType, sectionId.toString()], + reference + ); + return Array.isArray(results) ? results.map(r => new CustomQuestion(r)) : []; + } + + + /** + * Find all custom questions of a specific section type within a template customization. + * For example, find all custom questions added to BASE sections (as opposed to CUSTOM sections). + * + * @param reference The reference to use for logging errors. + * @param context The Apollo context. + * @param templateCustomizationId The id of the template customization. + * @param sectionType The type of the section to filter by (either 'BASE' or 'CUSTOM'). + * @returns The custom questions. + */ + static async findByCustomizationAndSectionType( + reference: string, + context: MyContext, + templateCustomizationId: number, + sectionType: PinnedSectionTypeEnum, + // No sectionId — we want ALL questions of this type across all sections + ): Promise { + const results = await CustomQuestion.query( + context, + `SELECT * FROM ${CustomQuestion.tableName} + WHERE templateCustomizationId = ? AND sectionType = ?`, + [templateCustomizationId.toString(), sectionType], + reference + ); + return Array.isArray(results) ? results.map(r => new CustomQuestion(r)) : []; + } + + /** + * Find a custom question by its position within a template customization. This is needed when + * calling moveCustomQuestion we need to check whether another customQuestion exists in the position + * that we want to move another custom question to. The position is determined by the section and pinned question it is attached to. + * + * @param reference The reference to use for logging errors. + * @param context The Apollo context. + * @param templateCustomizationId The id of the template customization. + * @param sectionType The type of the section. + * @param sectionId The id of the section. + * @param pinnedQuestionType The type of the pinned question. + * @param pinnedQuestionId The id of the pinned question. + * @returns The custom question or null if not found. + */ + static async findByPosition( + reference: string, + context: MyContext, + templateCustomizationId: number, + sectionType: string, + sectionId: number, + pinnedQuestionType: string | null, + pinnedQuestionId: number | null + ): Promise { + const sql = ` + SELECT * FROM customQuestions + WHERE templateCustomizationId = ? + AND sectionType = ? + AND sectionId = ? + AND pinnedQuestionType <=> ? + AND pinnedQuestionId <=> ? + LIMIT 1 + `; + // <=> is MySQL's NULL-safe equality operator. It correctly handles the case where pinnedQuestionId is null, + // since null = null is false in SQL but null <=> null is true. + const results = await CustomQuestion.query(context, sql, [ + templateCustomizationId.toString(), + sectionType, + sectionId.toString(), + pinnedQuestionType, + pinnedQuestionId?.toString() ?? null, + ], reference); + + return Array.isArray(results) && results.length > 0 ? new CustomQuestion(results[0]) : null; + + } } diff --git a/src/models/Plan.ts b/src/models/Plan.ts index 3ef2412c..d4ac6f13 100644 --- a/src/models/Plan.ts +++ b/src/models/Plan.ts @@ -44,6 +44,7 @@ export class PlanSearchResult { public funding: string; public members: string; public templateTitle: string; + public versionedTemplateId: number; // The following fields will only be set when the plan is published! public dmpId: string; @@ -63,6 +64,7 @@ export class PlanSearchResult { this.funding = options.funding; this.members = options.members; this.templateTitle = options.title; + this.versionedTemplateId = options.versionedTemplateId; this.dmpId = options.dmpId; this.registeredBy = options.registeredBy; @@ -108,13 +110,21 @@ export class PlanSearchResult { } } +export enum PlanSectionType { + BASE = 'BASE', + CUSTOM = 'CUSTOM', +} + + /** * Class that represents the progress of a plan section. * This includes the total number of questions and the percentage of questions * answered across all sections of the template. */ export class PlanSectionProgress { - public versionedSectionId: number; + public sectionType: PlanSectionType; + public versionedSectionId: number | null; // null for CUSTOM sections + public customSectionId?: number | null; // null for BASE sections public title: string; public displayOrder: number; public totalQuestions: number; @@ -122,7 +132,9 @@ export class PlanSectionProgress { public tags?: Tag[]; constructor(options) { - this.versionedSectionId = options.versionedSectionId; + this.sectionType = options.sectionType ?? PlanSectionType.BASE; + this.versionedSectionId = options.versionedSectionId ?? null; + this.customSectionId = options.customSectionId ?? null; this.title = options.title; this.displayOrder = options.displayOrder; this.totalQuestions = options.totalQuestions; @@ -131,21 +143,143 @@ export class PlanSectionProgress { } /** - * Return the progress information for the plan by section. + * Look up the templateCustomizationId for a given versionedTemplateId, if one exists. + * A template may not have been customized, in which case this returns undefined. + */ + private static async findTemplateCustomizationId( + reference: string, + context: MyContext, + versionedTemplateId: number, + affiliationId: string, + ): Promise { + const sql = ` + SELECT templateCustomizationId + FROM versionedTemplateCustomizations + WHERE currentVersionedTemplateId = ? + AND affiliationId = ? + LIMIT 1 + `; + const rows = await Plan.query(context, sql, [versionedTemplateId.toString(), affiliationId], reference); + return Array.isArray(rows) && rows.length > 0 + ? rows[0].templateCustomizationId + : undefined; + } + + /** + * Fetch custom sections for a given templateCustomizationId, including + * how many custom questions belong to each one. + */ + private static async fetchCustomSections( + reference: string, + context: MyContext, + templateCustomizationId: number, + ): Promise<{ id: number; name: string; pinnedSectionType: string; pinnedSectionId: number; totalQuestions: number }[]> { + const sql = ` + SELECT + vcs.customSectionId AS id, + vcs.name, + vcs.pinnedVersionedSectionType AS pinnedSectionType, + vcs.pinnedVersionedSectionId AS pinnedSectionId, + COUNT(vcq.id) AS totalQuestions + FROM versionedCustomSections vcs + JOIN versionedTemplateCustomizations vtc ON vtc.id = vcs.versionedTemplateCustomizationId + LEFT JOIN versionedCustomQuestions vcq + ON vcq.versionedSectionId = vcs.customSectionId + AND vcq.versionedSectionType = 'CUSTOM' + AND vcq.versionedTemplateCustomizationId = vtc.id + WHERE vtc.templateCustomizationId = ? + AND vtc.active = 1 + GROUP BY vcs.customSectionId, vcs.name, vcs.pinnedVersionedSectionType, vcs.pinnedVersionedSectionId + `; + const rows = await Plan.query(context, sql, [templateCustomizationId.toString()], reference); + return Array.isArray(rows) ? rows : []; + } + + /** + * Fetch the count of custom questions added to a BASE section for a given templateCustomizationId. + * This allows us to adjust the total question count for base sections that have extra custom questions added to them. + */ + private static async fetchExtraQuestionsForBaseSections( + reference: string, + context: MyContext, + templateCustomizationId: number + ): Promise<{ versionedSectionId: number; extraCount: number }[]> { + // sectionId on a BASE custom question points directly to versionedSections.id + const sql = ` + SELECT + cq.sectionId AS versionedSectionId, + COUNT(cq.id) AS extraCount + FROM customQuestions cq + JOIN versionedSections vs ON vs.id = cq.sectionId + WHERE cq.templateCustomizationId = ? + GROUP BY cq.sectionId + `; + const rows = await Plan.query( + context, + sql, + [templateCustomizationId.toString()], + reference + ); + return Array.isArray(rows) ? rows : []; + } + + /** + * Fetch the count of custom questions that have been answered by section type for a given plan and template customization. + * This allows us to credit answered custom questions in the progress calculation for both base and custom sections. + * @param reference + * @param context + * @param planId + * @param templateCustomizationId + * @returns + */ + private static async fetchAnsweredCustomQuestions( + reference: string, + context: MyContext, + planId: number, + templateCustomizationId: number, + ): Promise<{ sectionId: number; sectionType: string; answeredCount: number }[]> { + const sql = ` + SELECT + vcq.versionedSectionId AS sectionId, + vcq.versionedSectionType AS sectionType, + COUNT(DISTINCT a.versionedCustomQuestionId) AS answeredCount + FROM answers a + JOIN versionedCustomQuestions vcq + ON vcq.id = a.versionedCustomQuestionId + JOIN versionedTemplateCustomizations vtc + ON vtc.id = vcq.versionedTemplateCustomizationId + WHERE a.planId = ? + AND vtc.templateCustomizationId = ? + AND JSON_TYPE(a.json) = 'OBJECT' + GROUP BY vcq.versionedSectionId, vcq.versionedSectionType + `; + const rows = await Plan.query( + context, sql, + [planId.toString(), templateCustomizationId.toString()], + reference + ); + return Array.isArray(rows) ? rows : []; + } + + /** + * Return the progress information for the plan by section, including any + * custom sections or custom questions added via a template customization * * @param reference The caller's reference string for logging purposes' * @param context The Apollo context object * @param planId The ID of the plan to return progress information for * @returns The progress information for the section or an empty array if the section does not exist */ - static async findByPlanId(reference: string, context: MyContext, planId: number): Promise { + static async findByPlanId(reference: string, context: MyContext, planId: number, versionedTemplateId?: number): Promise { + // First fetch base sections and their question counts, which we will use as the foundation to build out the full section list with custom sections + // and adjusted question counts const sql = `SELECT vs.id AS versionedSectionId, vs.displayOrder, vs.name AS title, COUNT(DISTINCT vq.id) AS totalQuestions, COUNT(DISTINCT CASE - WHEN a.id IS NOT NULL AND NULLIF(TRIM(a.json), '') IS NOT NULL + WHEN a.id IS NOT NULL AND JSON_TYPE(a.json) = 'OBJECT' THEN vq.id END) AS answeredQuestions, COALESCE(tagAgg.tags, JSON_ARRAY()) AS tags @@ -177,14 +311,110 @@ export class PlanSectionProgress { ` const results = await Plan.query(context, sql, [planId?.toString()], reference); - return Array.isArray(results) + const baseSections: PlanSectionProgress[] = Array.isArray(results) ? results.map((entry) => { if (entry.tags && typeof entry.tags === 'string') { try { entry.tags = JSON.parse(entry.tags); } catch { entry.tags = []; } } - return new PlanSectionProgress(entry); + return new PlanSectionProgress({ ...entry, sectionType: PlanSectionType.BASE }); }) : []; + + // If there are no base sections the plan is in a bad state — return early + if (!baseSections.length) return baseSections; + + const affiliationId = context.token?.affiliationId; + if (!affiliationId) return baseSections; + + const templateCustomizationId = await this.findTemplateCustomizationId( + reference, + context, + versionedTemplateId, + affiliationId + ); + + // No customization exists for this template — return base sections as-is + // totalQuestions and answeredQuestions will reflect only the base questions in this case + if (!templateCustomizationId) return baseSections; + + // Fetch custom sections and extra question counts in parallel + const [customSectionTotals, baseCustomQuestionTotals, answeredCustomTotals] = await Promise.all([ + this.fetchCustomSections(reference, context, templateCustomizationId), + this.fetchExtraQuestionsForBaseSections(reference, context, templateCustomizationId), + this.fetchAnsweredCustomQuestions(reference, context, planId, templateCustomizationId), + ]); + + // Build answered-count maps keyed by sectionId, split by section type ("Base" vs "Custom") since they have different sectionId spaces + const answeredCustomByBaseSection = new Map(); + const answeredCustomByCustomSection = new Map(); + + for (const row of answeredCustomTotals) { + if (row.sectionType === 'BASE') { + answeredCustomByBaseSection.set(row.sectionId, Number(row.answeredCount)); + } else if (row.sectionType === 'CUSTOM') { + answeredCustomByCustomSection.set(row.sectionId, Number(row.answeredCount)); + } + } + + // Bump totalQuestions on base sections that have extra custom questions + if (baseCustomQuestionTotals.length) { + const extraBySection = new Map( + baseCustomQuestionTotals.map((r) => [r.versionedSectionId, Number(r.extraCount)]) + ); + for (const section of baseSections) { + const extra = extraBySection.get(section.versionedSectionId) ?? 0; + if (extra > 0) section.totalQuestions += extra; + + // Also credit answered custom questions on this base section + const answeredExtra = answeredCustomByBaseSection.get(section.versionedSectionId) ?? 0; + if (answeredExtra > 0) section.answeredQuestions += answeredExtra; + } + } + + // Build a map: pinnedId → custom sections pinned to it + const pinnedToMap = new Map(); + for (const cs of customSectionTotals) { + const existing = pinnedToMap.get(cs.pinnedSectionId) ?? []; + existing.push(cs); + pinnedToMap.set(cs.pinnedSectionId, existing); + } + + // Recursively collect custom sections inserted after a given target id + function collectAfter(targetId: number, result: typeof customSectionTotals, visited = new Set()) { + if (visited.has(targetId)) return; + visited.add(targetId); + const pinned = (pinnedToMap.get(targetId) ?? []).sort((a, b) => a.id - b.id); + for (const cs of pinned) { + result.push(cs); + collectAfter(cs.id, result, visited); + } + } + + // Walk base sections in order, inserting custom section chains after each one + const orderedSections: PlanSectionProgress[] = []; + let displayOrder = 0; + + for (const base of baseSections) { + orderedSections.push(new PlanSectionProgress({ ...base, displayOrder: displayOrder++ })); + + const chain: typeof customSectionTotals = []; + collectAfter(base.versionedSectionId, chain); + + for (const cs of chain) { + orderedSections.push(new PlanSectionProgress({ + sectionType: PlanSectionType.CUSTOM, + customSectionId: cs.id, + versionedSectionId: null, + title: cs.name, + displayOrder: displayOrder++, + totalQuestions: Number(cs.totalQuestions), + answeredQuestions: answeredCustomByCustomSection.get(cs.id) ?? 0, + tags: [], + })); + } + } + + return orderedSections; } } @@ -214,24 +444,26 @@ export class PlanProgress { * @param planId The ID of the plan to return progress information for * @returns The overall progress information for the plan or null if the plan does not exist */ - static async findByPlanId(reference: string, context: MyContext, planId: number): Promise { - const sql = `SELECT COUNT(DISTINCT vq.id) AS totalQuestions, - COUNT(DISTINCT CASE - WHEN a.id IS NOT NULL AND NULLIF(TRIM(a.json), '') IS NOT NULL - THEN vq.id - END) AS answeredQuestions - FROM plans p - JOIN versionedTemplates vt ON vt.id = p.versionedTemplateId - JOIN versionedSections vs ON vs.versionedTemplateId = vt.id - JOIN versionedQuestions vq ON vq.versionedSectionId = vs.id - LEFT JOIN answers a - ON a.planId = p.id - AND a.versionedQuestionId = vq.id - WHERE p.id = ?; -` - - const results = await Plan.query(context, sql, [planId?.toString()], reference); - return Array.isArray(results) && results.length > 0 ? new PlanProgress(results[0]) : null; + static async findByPlanId( + reference: string, + context: MyContext, + planId: number, + versionedTemplateId?: number + ): Promise { + // Reuse PlanSectionProgress which already handles custom questions correctly + const sections = await PlanSectionProgress.findByPlanId( + reference, + context, + planId, + versionedTemplateId + ); + + if (!sections.length) return null; + + const totalQuestions = sections.reduce((sum, s) => sum + s.totalQuestions, 0); + const answeredQuestions = sections.reduce((sum, s) => sum + s.answeredQuestions, 0); + + return new PlanProgress({ totalQuestions, answeredQuestions }); } } diff --git a/src/models/Project.ts b/src/models/Project.ts index d7c4ceb7..67ea0aa5 100644 --- a/src/models/Project.ts +++ b/src/models/Project.ts @@ -119,7 +119,7 @@ export class ProjectSearchResult { values.push(affiliationId); } - const sqlStatement = 'SELECT p.id, p.title, p.abstractText, p.startDate, p.endDate, p.isTestProject, ' + + const sqlStatement = 'SELECT p.id, p.title, p.abstractText, p.startDate, p.endDate, p.isTestProject, ' + 'researchDomains.description as researchDomain, ' + 'p.createdById, p.created, TRIM(CONCAT(cu.givenName, CONCAT(\' \', cu.surName))) as createdByName, ' + 'p.modifiedById, p.modified, TRIM(CONCAT(mu.givenName, CONCAT(\' \', mu.surName))) as modifiedByName, ' + diff --git a/src/models/TemplateCustomization.ts b/src/models/TemplateCustomization.ts index c097ed55..0cd0f909 100644 --- a/src/models/TemplateCustomization.ts +++ b/src/models/TemplateCustomization.ts @@ -8,6 +8,10 @@ import { } from "../utils/helpers"; import { PinnedSectionTypeEnum } from "./CustomSection"; import { PinnedQuestionTypeEnum } from "./CustomQuestion"; +import { + snapshotCustomizationChildren, + rollbackPublishedSnapshot, +} from "../services/templateCustomizationPublishHelpers"; /** * The status of the customization. @@ -619,12 +623,13 @@ export class TemplateCustomization extends MySqlModel { } /** - * Publish the customization + * Publish the template customization * * @param context The Apollo context. * @returns The published Template customization. */ async publish(context: MyContext): Promise { + const ref = 'TemplateCustomization.publish'; if (!this.id) { // Cannot publish it if it hasn't been saved yet! @@ -639,27 +644,36 @@ export class TemplateCustomization extends MySqlModel { if (await this.isValid()) { // Create a new published version of the customization - const newVersion = new VersionedTemplateCustomization( - { - affiliationId: this.affiliationId, - templateCustomizationId: this.id, - currentVersionedTemplateId: this.currentVersionedTemplateId, - active: true - } - ) + const newVersion = new VersionedTemplateCustomization({ + affiliationId: this.affiliationId, + templateCustomizationId: this.id, + currentVersionedTemplateId: this.currentVersionedTemplateId, + active: true, + }); const created: VersionedTemplateCustomization = await newVersion.create(context); if (!isNullOrUndefined(created) && !created.hasErrors() && created.id) { - // Update the status of the customization to reflect the change - this.status = TemplateCustomizationStatus.PUBLISHED; - this.isDirty = false; - this.latestPublishedVersionId = created.id; - this.latestPublishedDate = created.created; - const published: TemplateCustomization = await this.update(context, true); // noTouch=true, the update method will not set isDirty to true - - if (!published) { - this.addError('general', 'Unable to publish'); + // Snapshot all child records into their published, versioned equivalents + await snapshotCustomizationChildren(ref, context, this, created); + + if (this.hasErrors()) { + // Roll back: remove all child rows and the incomplete snapshot, + // then restore the prior active version. + await rollbackPublishedSnapshot( + context, created.id, this.latestPublishedVersionId); + } else { + // Update the status of the customization to reflect the change + this.status = TemplateCustomizationStatus.PUBLISHED; + this.isDirty = false; + this.latestPublishedVersionId = created.id; + this.latestPublishedDate = created.created; + // noTouch=true so the update method will not set isDirty to true + const published: TemplateCustomization = await this.update(context, true); + + if (!published) { + this.addError('general', 'Unable to publish'); + } } } else { this.errors = created?.errors ?? this.errors; diff --git a/src/models/VersionedCustomQuestion.ts b/src/models/VersionedCustomQuestion.ts index 37efcd83..b3ad39f6 100644 --- a/src/models/VersionedCustomQuestion.ts +++ b/src/models/VersionedCustomQuestion.ts @@ -321,4 +321,48 @@ export class VersionedCustomQuestion extends MySqlModel { ); return Array.isArray(results) && results.length > 0 ? new VersionedCustomQuestion(results[0]) : undefined; } + + // Find all the custom question versions for a specific versioned custom section version + // Custom questions use pinning, and not displayOrder + static async findByVersionedCustomSectionId( + reference: string, + context: MyContext, + versionedCustomSectionId: number + ): Promise { + const sql = ` + SELECT vcq.* FROM ${VersionedCustomQuestion.tableName} as vcq + JOIN versionedTemplateCustomizations as vtc + ON vcq.versionedTemplateCustomizationId = vtc.id + WHERE vcq.versionedSectionType = 'CUSTOM' + AND vcq.versionedSectionId = ? + AND vtc.active = 1 + ORDER BY vcq.pinnedVersionedQuestionType ASC, vcq.pinnedVersionedQuestionId ASC + `; + const results = await VersionedCustomQuestion.query( + context, sql, [versionedCustomSectionId.toString()], reference + ); + return Array.isArray(results) && results.length > 0 + ? results.map(r => new VersionedCustomQuestion(r)) + : []; + } + + // Find all the custom question versions for a specific versioned section version and section type + static async findByVersionedSectionIdAndType( + reference: string, + context: MyContext, + versionedSectionId: number, + sectionType: 'BASE' | 'CUSTOM' + ): Promise { + const sql = `SELECT vcq.* FROM versionedCustomQuestions as vcq + JOIN versionedTemplateCustomizations as vtc + ON vcq.versionedTemplateCustomizationId = vtc.id + WHERE vcq.versionedSectionType = ? + AND vcq.versionedSectionId = ? + AND vtc.active = 1 + ORDER BY vcq.pinnedVersionedQuestionType ASC, vcq.pinnedVersionedQuestionId ASC`; + const results = await VersionedCustomQuestion.query( + context, sql, [sectionType, versionedSectionId.toString()], reference + ); + return Array.isArray(results) ? results.map(r => new VersionedCustomQuestion(r)) : []; + } } diff --git a/src/models/VersionedCustomSection.ts b/src/models/VersionedCustomSection.ts index 87215476..d64f5c04 100644 --- a/src/models/VersionedCustomSection.ts +++ b/src/models/VersionedCustomSection.ts @@ -231,6 +231,39 @@ export class VersionedCustomSection extends MySqlModel { return Array.isArray(results) && results.length > 0 ? new VersionedCustomSection(results[0]) : undefined; } + // Find the custom section version for a specific plan and custom section id, if it exists + static async findByPlanAndSectionId( + reference: string, + context: MyContext, + planId: number, + customSectionId: number, + affiliationId: string + ): Promise { + const sql = ` + SELECT vcs.* + FROM versionedCustomSections vcs + INNER JOIN versionedTemplateCustomizations vtc + ON vcs.versionedTemplateCustomizationId = vtc.id + INNER JOIN plans p + ON vtc.currentVersionedTemplateId = p.versionedTemplateId + WHERE p.id = ? + AND vcs.customSectionId = ? + AND vtc.affiliationId = ? + AND vtc.active = 1 + ORDER BY vtc.modified DESC + LIMIT 1 + `; + const results = await VersionedCustomSection.query( + context, + sql, + [planId.toString(), customSectionId.toString(), affiliationId], + reference + ); + return Array.isArray(results) && results.length > 0 + ? new VersionedCustomSection(results[0]) + : undefined; + } + /** * Find all the custom section versions for a specific template customization version * diff --git a/src/models/VersionedQuestionCustomization.ts b/src/models/VersionedQuestionCustomization.ts index 2161e3eb..68c144d8 100644 --- a/src/models/VersionedQuestionCustomization.ts +++ b/src/models/VersionedQuestionCustomization.ts @@ -234,4 +234,41 @@ export class VersionedQuestionCustomization extends MySqlModel { ); return Array.isArray(results) && results.length > 0 ? results.map(r => new VersionedQuestionCustomization(r)) : []; } + + /** + * Find the active versioned question customization for a given template and affiliation. + * Used to surface customization guidance in the plan guidance panel. + * + * + * @param reference The reference to use for logging errors. + * @param context The Apollo context. + * @param templateId The base template id. + * @param affiliationId The affiliation id. + * @param versionedQuestionId The versioned question id. + * @returns The active versioned question customization, or undefined if none exists. + */ + static async findActiveByTemplateAffiliationAndQuestion( + reference: string, + context: MyContext, + affiliationId: string, + versionedQuestionId: number + ): Promise { + const results = await VersionedQuestionCustomization.query( + context, + `SELECT vqc.*, vtc.affiliationId as customizationAffiliationId + FROM ${VersionedQuestionCustomization.tableName} AS vqc + JOIN versionedTemplateCustomizations AS vtc + ON vqc.versionedTemplateCustomizationId = vtc.id + WHERE vtc.active = 1 + AND vtc.affiliationId = ? + AND vqc.versionedQuestionId = ? + LIMIT 1`, + [affiliationId, versionedQuestionId.toString()], + reference + ); + return Array.isArray(results) && results.length > 0 + ? new VersionedQuestionCustomization(results[0]) + : undefined; + } + } diff --git a/src/models/VersionedSectionCustomization.ts b/src/models/VersionedSectionCustomization.ts index 379741cd..9be18ebb 100644 --- a/src/models/VersionedSectionCustomization.ts +++ b/src/models/VersionedSectionCustomization.ts @@ -231,4 +231,38 @@ export class VersionedSectionCustomization extends MySqlModel { ); return Array.isArray(results) && results.length > 0 ? results.map(r => new VersionedSectionCustomization(r)) : []; } + + + /** + * Find the active versioned section customization for a given affiliation and section. + * Used to surface customization guidance in the plan guidance panel. + * + * @param reference The reference to use for logging errors. + * @param context The Apollo context. + * @param affiliationId The affiliation id. + * @param versionedSectionId The versioned section id. + * @returns The active versioned section customization, or undefined if none exists. + */ + static async findActiveByTemplateAffiliationAndSection( + reference: string, + context: MyContext, + affiliationId: string, + versionedSectionId: number + ): Promise { + const results = await VersionedSectionCustomization.query( + context, + `SELECT vsc.* FROM ${VersionedSectionCustomization.tableName} AS vsc + JOIN versionedTemplateCustomizations AS vtc + ON vsc.versionedTemplateCustomizationId = vtc.id + WHERE vtc.active = 1 + AND vtc.affiliationId = ? + AND vsc.versionedSectionId = ? + LIMIT 1`, + [affiliationId, versionedSectionId.toString()], + reference + ); + return Array.isArray(results) && results.length > 0 + ? new VersionedSectionCustomization(results[0]) + : undefined; + } } diff --git a/src/models/VersionedTemplate.ts b/src/models/VersionedTemplate.ts index 18db8f0b..2585c461 100644 --- a/src/models/VersionedTemplate.ts +++ b/src/models/VersionedTemplate.ts @@ -38,6 +38,7 @@ export class VersionedTemplateSearchResult { public modifiedById: number; public modifiedByName: string; public modified: string; + public versionedTemplateCustomizationId?: number; constructor(options) { this.id = options.id; @@ -54,6 +55,7 @@ export class VersionedTemplateSearchResult { this.modifiedById = options.modifiedById; this.modifiedByName = options.modifiedByName; this.modified = options.modified; + this.versionedTemplateCustomizationId = options.versionedTemplateCustomizationId; } // Find all of the high level details about the published templates matching the search term @@ -63,6 +65,7 @@ export class VersionedTemplateSearchResult { term: string, options: TemplateQueryOptions = VersionedTemplate.getDefaultPaginationOptions(), ): Promise> { + const userAffiliationId = context.token?.affiliationId; const whereFilters = ['vt.active = 1 AND vt.versionType = ?']; const values = [TemplateVersionType.PUBLISHED.toString()]; @@ -103,13 +106,22 @@ export class VersionedTemplateSearchResult { opts.cursorField = 'vt.id'; } - const sqlStatement = 'SELECT vt.id, vt.templateId, vt.name, vt.description, vt.version, vt.visibility, vt.bestPractice, \ - vt.modified, vt.modifiedById, TRIM(CONCAT(u.givenName, CONCAT(\' \', u.surName))) as modifiedByName, \ - a.id as ownerId, vt.ownerId as ownerURI, a.displayName as ownerDisplayName, \ - a.searchName as ownerSearchName \ - FROM versionedTemplates vt \ - LEFT JOIN users u ON u.id = vt.modifiedById \ - LEFT JOIN affiliations a ON a.uri = vt.ownerId'; + const sqlStatement = [ + 'SELECT vt.id, vt.templateId, vt.name, vt.description, vt.version, vt.visibility, vt.bestPractice,', + 'vt.modified, vt.modifiedById, TRIM(CONCAT(u.givenName, " ", u.surName)) as modifiedByName,', + 'a.id as ownerId, vt.ownerId as ownerURI, a.displayName as ownerDisplayName,', + 'a.searchName as ownerSearchName,', + 'vtc.id as versionedTemplateCustomizationId', + 'FROM versionedTemplates vt', + 'LEFT JOIN users u ON u.id = vt.modifiedById', + 'LEFT JOIN affiliations a ON a.uri = vt.ownerId', + 'LEFT JOIN versionedTemplateCustomizations vtc', + 'ON vtc.currentVersionedTemplateId = vt.id', + 'AND vtc.affiliationId=?', + 'AND vtc.active = 1' + ].join(' '); + + values.unshift(userAffiliationId); const response: PaginatedQueryResults = await VersionedTemplate.queryWithPagination( context, diff --git a/src/models/__mocks__/Plan.ts b/src/models/__mocks__/Plan.ts index 96aee2eb..ed6ede42 100644 --- a/src/models/__mocks__/Plan.ts +++ b/src/models/__mocks__/Plan.ts @@ -67,6 +67,7 @@ const planToPlanSearchResult = (plan: Plan): PlanSearchResult => { funding: casual.company_name, members: casual.full_name, templateTitle: casual.title, + versionedTemplateId: plan.versionedTemplateId, } } diff --git a/src/models/__tests__/Answer.spec.ts b/src/models/__tests__/Answer.spec.ts index 31f9edeb..45e13896 100644 --- a/src/models/__tests__/Answer.spec.ts +++ b/src/models/__tests__/Answer.spec.ts @@ -29,6 +29,7 @@ describe('Answer', () => { versionedSectionId: casual.integer(1, 9999), json: "{\"type\":\"textArea\",\"answer\":\"California\",\"meta\":{\"schemaVersion\":\"${CURRENT_SCHEMA_VERSION}\"}}" } + beforeEach(() => { answer = new Answer(answerData); }); @@ -37,6 +38,8 @@ describe('Answer', () => { expect(answer.planId).toEqual(answerData.planId); expect(answer.versionedSectionId).toEqual(answerData.versionedSectionId); expect(answer.versionedQuestionId).toEqual(answerData.versionedQuestionId); + expect(answer.versionedCustomSectionId).toBeUndefined(); + expect(answer.versionedCustomQuestionId).toBeUndefined(); expect(answer.json).toEqual(answerData.json); }); @@ -48,14 +51,14 @@ describe('Answer', () => { answer.versionedSectionId = null; expect(await answer.isValid()).toBe(false); expect(Object.keys(answer.errors).length).toBe(1); - expect(answer.errors['versionedSectionId']).toBeTruthy(); + expect(answer.errors['general']).toBeTruthy(); }); it('should return false when calling isValid if the versionedQuestionId field is missing', async () => { answer.versionedQuestionId = null; expect(await answer.isValid()).toBe(false); expect(Object.keys(answer.errors).length).toBe(1); - expect(answer.errors['versionedQuestionId']).toBeTruthy(); + expect(answer.errors['general']).toBeTruthy(); }); it('should return false when calling isValid if the planId field is missing', async () => { @@ -64,6 +67,203 @@ describe('Answer', () => { expect(Object.keys(answer.errors).length).toBe(1); expect(answer.errors['planId']).toBeTruthy(); }); + + it('prepForSave should NOT add the default output type column if the answer is NOT a ResearchOutputTableAnswer', async () => { + answer.json = `{"type":"textArea","answer":"California","meta":{"schemaVersion":"${CURRENT_SCHEMA_VERSION}"}}`; + answer.prepForSave(); + expect(answer.json).toEqual(answer.json); + }); + + it('prepForSave should NOT add the default output type column if the answer is a ResearchOutputTableAnswer but has one defined', async () => { + answer.json = JSON.stringify({ + type: "researchOutputTable", + columnHeadings: ["Title", "Type"], + answer: [{ + columns: [ + { + type: "text", + commonStandardId: 'title', + answer: "", + meta: { schemaVersion: CURRENT_SCHEMA_VERSION }, + }, + { + type: "selectBox", + commonStandardId: 'type', + answer: "", + meta: { schemaVersion: CURRENT_SCHEMA_VERSION }, + } + ] + }], + meta: { + schemaVersion: CURRENT_SCHEMA_VERSION, + } + }); + answer.prepForSave(); + expect(answer.json).toEqual(answer.json); + }); + + it('prepForSave should add the default output type column if the answer is a ResearchOutputTableAnswer and it is missing', async () => { + const baseJSON = { + type: "researchOutputTable", + columnHeadings: ["Title", "Type"], + answer: [{ + columns: [ + { + type: "text", + commonStandardId: 'title', + answer: "", + meta: { schemaVersion: CURRENT_SCHEMA_VERSION }, + } + ] + }], + meta: { + schemaVersion: CURRENT_SCHEMA_VERSION, + } + }; + answer.json = JSON.stringify(baseJSON); + + const expectedJSON = JSON.stringify({ + type: "researchOutputTable", + columnHeadings: ["Title", "Type"], + answer: [{ + columns: [ + ...baseJSON.answer[0].columns, + { + type: "selectBox", + commonStandardId: 'type', + answer: "Unknown", + meta: { schemaVersion: CURRENT_SCHEMA_VERSION }, + } + ] + }], + meta: { schemaVersion: CURRENT_SCHEMA_VERSION } + }); + answer.prepForSave(); + expect(answer.json).toEqual(expectedJSON); + }); + + it('prepForSave should add the default output type column if the answer is a ResearchOutputTableAnswer and it is blank', async () => { + const baseJSON = { + type: "researchOutputTable", + columnHeadings: ["Title", "Type"], + answer: [ + { + columns: [ + { + type: "text", + commonStandardId: 'title', + answer: "", + meta: { schemaVersion: CURRENT_SCHEMA_VERSION }, + }, + { + type: "selectBox", + commonStandardId: 'type', + answer: "dataset", + meta: { schemaVersion: CURRENT_SCHEMA_VERSION }, + } + ] + }, + { + columns: [ + { + type: "text", + commonStandardId: 'title', + answer: "", + meta: { schemaVersion: CURRENT_SCHEMA_VERSION }, + }, + { + type: "selectBox", + commonStandardId: 'type', + answer: "", + meta: { schemaVersion: CURRENT_SCHEMA_VERSION }, + } + ] + } + ], + meta: { + schemaVersion: CURRENT_SCHEMA_VERSION, + } + }; + answer.json = JSON.stringify(baseJSON); + + const expectedJSON = baseJSON; + expectedJSON.answer[1].columns[1].answer = "Unknown"; + answer.prepForSave(); + expect(answer.json).toEqual(JSON.stringify(expectedJSON)); + }); +}); + +describe('Answer - CUSTOM question', () => { + let answer; + + const customAnswerData = { + planId: casual.integer(1, 9999), + versionedCustomSectionId: casual.integer(1, 9999), + versionedCustomQuestionId: casual.integer(1, 9999), + json: `{"type":"textArea","answer":"California","meta":{"schemaVersion":"${CURRENT_SCHEMA_VERSION}"}}` + }; + + beforeEach(() => { + answer = new Answer(customAnswerData); + }); + + it('should initialize options as expected', () => { + expect(answer.planId).toEqual(customAnswerData.planId); + expect(answer.versionedCustomSectionId).toEqual(customAnswerData.versionedCustomSectionId); + expect(answer.versionedCustomQuestionId).toEqual(customAnswerData.versionedCustomQuestionId); + expect(answer.versionedSectionId).toBeUndefined(); + expect(answer.versionedQuestionId).toBeUndefined(); + expect(answer.json).toEqual(customAnswerData.json); + }); + + it('should return true when calling isValid if object is valid', async () => { + expect(await answer.isValid()).toBe(true); + }); + + it('should return false when calling isValid if versionedCustomSectionId is missing', async () => { + answer.versionedCustomSectionId = null; + expect(await answer.isValid()).toBe(false); + expect(Object.keys(answer.errors).length).toBe(1); + expect(answer.errors['general']).toBeTruthy(); + }); + + it('should return false when calling isValid if versionedCustomQuestionId is missing', async () => { + answer.versionedCustomQuestionId = null; + expect(await answer.isValid()).toBe(false); + expect(Object.keys(answer.errors).length).toBe(1); + expect(answer.errors['general']).toBeTruthy(); + }); + + it('should return false when calling isValid if planId is missing', async () => { + answer.planId = null; + expect(await answer.isValid()).toBe(false); + expect(Object.keys(answer.errors).length).toBe(1); + expect(answer.errors['planId']).toBeTruthy(); + }); +}); + +describe('Answer - invalid combinations', () => { + it('should return false when both base and custom IDs are set', async () => { + const answer = new Answer({ + planId: casual.integer(1, 9999), + versionedSectionId: casual.integer(1, 9999), + versionedQuestionId: casual.integer(1, 9999), + versionedCustomSectionId: casual.integer(1, 9999), + versionedCustomQuestionId: casual.integer(1, 9999), + json: `{"type":"textArea","answer":"California","meta":{"schemaVersion":"${CURRENT_SCHEMA_VERSION}"}}` + }); + expect(await answer.isValid()).toBe(false); + expect(answer.errors['general']).toBeTruthy(); + }); + + it('should return false when neither base nor custom IDs are set', async () => { + const answer = new Answer({ + planId: casual.integer(1, 9999), + json: `{"type":"textArea","answer":"California","meta":{"schemaVersion":"${CURRENT_SCHEMA_VERSION}"}}` + }); + expect(await answer.isValid()).toBe(false); + expect(answer.errors['general']).toBeTruthy(); + }); }); describe('findBy Queries', () => { @@ -147,7 +347,7 @@ describe('findBy Queries', () => { // Mock the localQuery to return an array of answers, these are the same answer repeated, but the mock simply // returns what is written here if it's called. So mocking additional different answers doesn't add additional value to the test. localQuery.mockResolvedValueOnce([answer, answer, answer]); - const questionIds = [ casual.integer(1, 9999), casual.integer(1, 9999), casual.integer(1, 9999)]; + const questionIds = [casual.integer(1, 9999), casual.integer(1, 9999), casual.integer(1, 9999)]; const result = await Answer.findFilledAnswersByQuestionIds('testing', context, planId, questionIds); const expectedSql = 'SELECT * FROM answers WHERE planId = ? AND versionedQuestionId IN (?, ?, ?) AND json IS NOT NULL AND json != \'\''; const vals = questionIds.map(String) @@ -292,6 +492,8 @@ describe('create', () => { (Answer.findByPlanIdAndVersionedQuestionId as jest.Mock) = mockFindByPlanIdAndVersionedQuestionId; mockFindByPlanIdAndVersionedQuestionId.mockResolvedValueOnce(null); + insertQuery.mockResolvedValueOnce(casual.integer(1, 9999)); + const mockFindById = jest.fn(); (Answer.findById as jest.Mock) = mockFindById; mockFindById.mockResolvedValueOnce(answer); diff --git a/src/models/__tests__/CustomQuestion.spec.ts b/src/models/__tests__/CustomQuestion.spec.ts index 3e4d7305..2cc851c3 100644 --- a/src/models/__tests__/CustomQuestion.spec.ts +++ b/src/models/__tests__/CustomQuestion.spec.ts @@ -726,4 +726,129 @@ describe("CustomQuestion", () => { expect(result).toBeUndefined(); }); }); + + describe("findByPosition", () => { + const expectedSql = ` + SELECT * FROM customQuestions + WHERE templateCustomizationId = ? + AND sectionType = ? + AND sectionId = ? + AND pinnedQuestionType <=> ? + AND pinnedQuestionId <=> ? + LIMIT 1 + `; + + it("should find a CustomQuestion by position with pinnedQuestion values", async () => { + const mockQuery = jest.spyOn(MySqlModel, "query").mockResolvedValue([ + { + id: 1, + templateCustomizationId: 100, + sectionType: PinnedSectionTypeEnum.BASE, + sectionId: 200, + pinnedQuestionType: PinnedQuestionTypeEnum.BASE, + pinnedQuestionId: 300, + json: { + type: "text", + attributes: { maxLength: 100 }, + meta: { schemaVersion: "1.0" } + }, + questionText: "Test Question", + }, + ]); + + const result = await CustomQuestion.findByPosition( + "test.ref", + mockContext, + 100, + PinnedSectionTypeEnum.BASE, + 200, + PinnedQuestionTypeEnum.BASE, + 300 + ); + + expect(mockQuery).toHaveBeenCalledWith( + mockContext, + expectedSql, + ["100", PinnedSectionTypeEnum.BASE, "200", PinnedQuestionTypeEnum.BASE, "300"], + "test.ref" + ); + expect(result).toBeInstanceOf(CustomQuestion); + expect(result.id).toBe(1); + expect(result.templateCustomizationId).toBe(100); + expect(result.sectionId).toBe(200); + expect(result.pinnedQuestionId).toBe(300); + }); + + it("should return null when no record is found", async () => { + jest.spyOn(MySqlModel, "query").mockResolvedValue([]); + + const result = await CustomQuestion.findByPosition( + "test.ref", + mockContext, + 100, + PinnedSectionTypeEnum.BASE, + 200, + PinnedQuestionTypeEnum.BASE, + 300 + ); + + expect(result).toBeNull(); + }); + + it("should handle null pinnedQuestionType and pinnedQuestionId", async () => { + const mockQuery = jest.spyOn(MySqlModel, "query").mockResolvedValue([ + { + id: 2, + templateCustomizationId: 100, + sectionType: PinnedSectionTypeEnum.BASE, + sectionId: 200, + pinnedQuestionType: null, + pinnedQuestionId: null, + json: { + type: "text", + attributes: { maxLength: 100 }, + meta: { schemaVersion: "1.0" } + }, + questionText: "First Question", + }, + ]); + + const result = await CustomQuestion.findByPosition( + "test.ref", + mockContext, + 100, + PinnedSectionTypeEnum.BASE, + 200, + null, + null + ); + + expect(mockQuery).toHaveBeenCalledWith( + mockContext, + expectedSql, + ["100", PinnedSectionTypeEnum.BASE, "200", null, null], + "test.ref" + ); + expect(result).toBeInstanceOf(CustomQuestion); + expect(result.id).toBe(2); + expect(result.pinnedQuestionType).toBeUndefined(); + expect(result.pinnedQuestionId).toBeNull(); + }); + + it("should return null when query result is not an array", async () => { + jest.spyOn(MySqlModel, "query").mockResolvedValue(null); + + const result = await CustomQuestion.findByPosition( + "test.ref", + mockContext, + 100, + PinnedSectionTypeEnum.BASE, + 200, + null, + null + ); + + expect(result).toBeNull(); + }); + }); }); diff --git a/src/models/__tests__/Plan.spec.ts b/src/models/__tests__/Plan.spec.ts index 8c7eca51..1875f722 100644 --- a/src/models/__tests__/Plan.spec.ts +++ b/src/models/__tests__/Plan.spec.ts @@ -188,18 +188,19 @@ describe('PlanSectionProgress.findByPlanId', () => { afterEach(() => { Plan.query = originalQuery; + jest.restoreAllMocks(); // moved here so all spyOn tests are cleaned up consistently }); it('should call the correct SQL query', async () => { - localQuery.mockResolvedValueOnce([progress]); const planId = casual.integer(1, 99); + const versionedTemplateId = casual.integer(1, 99); const sql = `SELECT vs.id AS versionedSectionId, vs.displayOrder, vs.name AS title, COUNT(DISTINCT vq.id) AS totalQuestions, COUNT(DISTINCT CASE - WHEN a.id IS NOT NULL AND NULLIF(TRIM(a.json), '') IS NOT NULL + WHEN a.id IS NOT NULL AND JSON_TYPE(a.json) = 'OBJECT' THEN vq.id END) AS answeredQuestions, COALESCE(tagAgg.tags, JSON_ARRAY()) AS tags @@ -229,10 +230,17 @@ describe('PlanSectionProgress.findByPlanId', () => { GROUP BY vs.id, vs.displayOrder, vs.name, tagAgg.tags ORDER BY vs.displayOrder; ` - const result = await PlanSectionProgress.findByPlanId('testing', context, planId); - expect(localQuery).toHaveBeenCalledTimes(1); - expect(localQuery).toHaveBeenLastCalledWith(context, sql, [planId.toString()], 'testing') - expect(result).toEqual([progress]); + + localQuery + .mockResolvedValueOnce([progress]) // first call: base sections query + .mockResolvedValueOnce([]); // second call: findTemplateCustomizationId returns no customization + + const result = await PlanSectionProgress.findByPlanId('testing', context, planId, versionedTemplateId); + + expect(localQuery).toHaveBeenCalledTimes(2); + expect(localQuery).toHaveBeenNthCalledWith(1, context, sql, [planId.toString()], 'testing'); + expect(localQuery).toHaveBeenNthCalledWith(2, context, expect.stringContaining('SELECT templateCustomizationId'), [versionedTemplateId.toString(), context.token.affiliationId], 'testing'); + expect(result).toHaveLength(1); }); it('should return an empty array if no results are found', async () => { @@ -241,9 +249,216 @@ describe('PlanSectionProgress.findByPlanId', () => { const result = await PlanSectionProgress.findByPlanId('testing', context, projectId); expect(result).toEqual([]); }); + + it('should return base sections only if no template customization exists', async () => { + const baseSection = { versionedSectionId: 1, displayOrder: 0, title: 'Base', totalQuestions: 2, answeredQuestions: 1, tags: [] }; + localQuery + .mockResolvedValueOnce([baseSection]) // base sections + .mockResolvedValueOnce(undefined); // findTemplateCustomizationId returns undefined + const result = await PlanSectionProgress.findByPlanId('ref', context, 123, 456); + expect(result).toHaveLength(1); + expect(result[0].versionedSectionId).toBe(1); + }); + + it('should bump totalQuestions for base sections with extra custom questions', async () => { + const baseSection = { versionedSectionId: 1, displayOrder: 0, title: 'Base', totalQuestions: 2, answeredQuestions: 1, tags: [] }; + localQuery.mockResolvedValueOnce([baseSection]); // base sections + + // Mock the static methods + /*eslint-disable @typescript-eslint/no-explicit-any */ + jest.spyOn(PlanSectionProgress as any, 'findTemplateCustomizationId').mockResolvedValue(99); + jest.spyOn(PlanSectionProgress as any, 'fetchCustomSections').mockResolvedValue([]); + jest.spyOn(PlanSectionProgress as any, 'fetchExtraQuestionsForBaseSections').mockResolvedValue([{ versionedSectionId: 1, extraCount: 3 }]); + jest.spyOn(PlanSectionProgress as any, 'fetchAnsweredCustomQuestions').mockResolvedValue([]); + + const result = await PlanSectionProgress.findByPlanId('ref', context, 123, 456); + expect(result[0].totalQuestions).toBe(5); + + // Clean up + jest.restoreAllMocks(); + }); + + it('should not bump totalQuestions if no extra questions exist', async () => { + const baseSection = { versionedSectionId: 1, displayOrder: 0, title: 'Base', totalQuestions: 2, answeredQuestions: 1, tags: [] }; + localQuery.mockResolvedValueOnce([baseSection]); + + /*eslint-disable @typescript-eslint/no-explicit-any */ + jest.spyOn(PlanSectionProgress as any, 'findTemplateCustomizationId').mockResolvedValue(99); + jest.spyOn(PlanSectionProgress as any, 'fetchCustomSections').mockResolvedValue([]); + jest.spyOn(PlanSectionProgress as any, 'fetchExtraQuestionsForBaseSections').mockResolvedValue([]); + jest.spyOn(PlanSectionProgress as any, 'fetchAnsweredCustomQuestions').mockResolvedValue([]); + + const result = await PlanSectionProgress.findByPlanId('ref', context, 123, 456); + expect(result[0].totalQuestions).toBe(2); + }); + + it('should insert custom sections after the correct base section', async () => { + const baseSection = { versionedSectionId: 1, displayOrder: 0, title: 'Base', totalQuestions: 2, answeredQuestions: 1, tags: [] }; + const customSection = { id: 10, name: 'Custom', pinnedSectionType: 'BASE', pinnedSectionId: 1, totalQuestions: 3 }; + localQuery.mockResolvedValueOnce([baseSection]); + + /*eslint-disable @typescript-eslint/no-explicit-any */ + jest.spyOn(PlanSectionProgress as any, 'findTemplateCustomizationId').mockResolvedValue(99); + jest.spyOn(PlanSectionProgress as any, 'fetchCustomSections').mockResolvedValue([customSection]); + jest.spyOn(PlanSectionProgress as any, 'fetchExtraQuestionsForBaseSections').mockResolvedValue([]); + jest.spyOn(PlanSectionProgress as any, 'fetchAnsweredCustomQuestions').mockResolvedValue([]); + + const result = await PlanSectionProgress.findByPlanId('ref', context, 123, 456); + expect(result).toHaveLength(2); + expect(result[1].sectionType).toBeDefined(); + expect(result[1].customSectionId).toBe(10); + expect(result[1].title).toBe('Custom'); + }); + + it('should insert chains of custom sections (custom sections pinned to other custom sections)', async () => { + const baseSection = { versionedSectionId: 1, displayOrder: 0, title: 'Base', totalQuestions: 2, answeredQuestions: 1, tags: [] }; + const customSection1 = { id: 10, name: 'Custom1', pinnedSectionType: 'BASE', pinnedSectionId: 1, totalQuestions: 3 }; + const customSection2 = { id: 11, name: 'Custom2', pinnedSectionType: 'CUSTOM', pinnedSectionId: 10, totalQuestions: 2 }; + localQuery.mockResolvedValueOnce([baseSection]); // base sections + + /*eslint-disable @typescript-eslint/no-explicit-any */ + jest.spyOn(PlanSectionProgress as any, 'findTemplateCustomizationId').mockResolvedValue(99); + jest.spyOn(PlanSectionProgress as any, 'fetchCustomSections').mockResolvedValue([customSection1, customSection2]); + jest.spyOn(PlanSectionProgress as any, 'fetchExtraQuestionsForBaseSections').mockResolvedValue([]); + jest.spyOn(PlanSectionProgress as any, 'fetchAnsweredCustomQuestions').mockResolvedValue([]); + + const result = await PlanSectionProgress.findByPlanId('ref', context, 123, 456); + expect(result).toHaveLength(3); + expect(result[1].customSectionId).toBe(10); + expect(result[2].customSectionId).toBe(11); + expect(result[2].title).toBe('Custom2'); + }); + + it('should credit answered custom questions to the correct base section', async () => { + const baseSection = { versionedSectionId: 1, displayOrder: 0, title: 'Base', totalQuestions: 2, answeredQuestions: 1, tags: [] }; + localQuery.mockResolvedValueOnce([baseSection]); + + /*eslint-disable @typescript-eslint/no-explicit-any */ + jest.spyOn(PlanSectionProgress as any, 'findTemplateCustomizationId').mockResolvedValue(99); + jest.spyOn(PlanSectionProgress as any, 'fetchCustomSections').mockResolvedValue([]); + jest.spyOn(PlanSectionProgress as any, 'fetchExtraQuestionsForBaseSections').mockResolvedValue([{ versionedSectionId: 1, extraCount: 2 }]); + jest.spyOn(PlanSectionProgress as any, 'fetchAnsweredCustomQuestions').mockResolvedValue([ + { sectionId: 1, sectionType: 'BASE', answeredCount: 2 }, + ]); + + const result = await PlanSectionProgress.findByPlanId('ref', context, 123, 456); + expect(result[0].answeredQuestions).toBe(3); // 1 base + 2 custom answered + }); + + it('should credit answered custom questions to the correct custom section', async () => { + const baseSection = { versionedSectionId: 1, displayOrder: 0, title: 'Base', totalQuestions: 2, answeredQuestions: 1, tags: [] }; + const customSection = { id: 10, name: 'Custom', pinnedSectionType: 'BASE', pinnedSectionId: 1, totalQuestions: 3 }; + localQuery.mockResolvedValueOnce([baseSection]); + + /*eslint-disable @typescript-eslint/no-explicit-any */ + jest.spyOn(PlanSectionProgress as any, 'findTemplateCustomizationId').mockResolvedValue(99); + jest.spyOn(PlanSectionProgress as any, 'fetchCustomSections').mockResolvedValue([customSection]); + jest.spyOn(PlanSectionProgress as any, 'fetchExtraQuestionsForBaseSections').mockResolvedValue([]); + jest.spyOn(PlanSectionProgress as any, 'fetchAnsweredCustomQuestions').mockResolvedValue([ + { sectionId: 10, sectionType: 'CUSTOM', answeredCount: 2 }, + ]); + + const result = await PlanSectionProgress.findByPlanId('ref', context, 123, 456); + const customResult = result.find(s => s.customSectionId === 10); + expect(customResult.answeredQuestions).toBe(2); + }); + + it('should not credit answered custom questions to the wrong section', async () => { + const baseSection = { versionedSectionId: 1, displayOrder: 0, title: 'Base', totalQuestions: 2, answeredQuestions: 1, tags: [] }; + const customSection = { id: 10, name: 'Custom', pinnedSectionType: 'BASE', pinnedSectionId: 1, totalQuestions: 3 }; + localQuery.mockResolvedValueOnce([baseSection]); + + /*eslint-disable @typescript-eslint/no-explicit-any */ + jest.spyOn(PlanSectionProgress as any, 'findTemplateCustomizationId').mockResolvedValue(99); + jest.spyOn(PlanSectionProgress as any, 'fetchCustomSections').mockResolvedValue([customSection]); + jest.spyOn(PlanSectionProgress as any, 'fetchExtraQuestionsForBaseSections').mockResolvedValue([]); + jest.spyOn(PlanSectionProgress as any, 'fetchAnsweredCustomQuestions').mockResolvedValue([ + { sectionId: 10, sectionType: 'CUSTOM', answeredCount: 2 }, + ]); + + const result = await PlanSectionProgress.findByPlanId('ref', context, 123, 456); + const baseResult = result.find(s => s.versionedSectionId === 1); + expect(baseResult.answeredQuestions).toBe(1); // unchanged — credits belong to the custom section + }); + + it('should handle no answered custom questions gracefully', async () => { + const baseSection = { versionedSectionId: 1, displayOrder: 0, title: 'Base', totalQuestions: 2, answeredQuestions: 1, tags: [] }; + localQuery.mockResolvedValueOnce([baseSection]); + + /*eslint-disable @typescript-eslint/no-explicit-any */ + jest.spyOn(PlanSectionProgress as any, 'findTemplateCustomizationId').mockResolvedValue(99); + jest.spyOn(PlanSectionProgress as any, 'fetchCustomSections').mockResolvedValue([]); + jest.spyOn(PlanSectionProgress as any, 'fetchExtraQuestionsForBaseSections').mockResolvedValue([]); + jest.spyOn(PlanSectionProgress as any, 'fetchAnsweredCustomQuestions').mockResolvedValue([]); + + const result = await PlanSectionProgress.findByPlanId('ref', context, 123, 456); + expect(result[0].answeredQuestions).toBe(1); // unchanged + }); + + it('should parse tags correctly if tags are a string', async () => { + const baseSection = { versionedSectionId: 1, displayOrder: 0, title: 'Base', totalQuestions: 2, answeredQuestions: 1, tags: '[{"id":1,"slug":"foo","name":"Foo","description":"desc"}]' }; + localQuery.mockResolvedValueOnce([baseSection]); + const result = await PlanSectionProgress.findByPlanId('ref', context, 123, 456); + expect(Array.isArray(result[0].tags)).toBe(true); + expect(result[0].tags[0].slug).toBe('foo'); + }); + + it('should handle empty or malformed tags gracefully', async () => { + const baseSection = { versionedSectionId: 1, displayOrder: 0, title: 'Base', totalQuestions: 2, answeredQuestions: 1, tags: 'not-json' }; + localQuery.mockResolvedValueOnce([baseSection]); + const result = await PlanSectionProgress.findByPlanId('ref', context, 123, 456); + expect(Array.isArray(result[0].tags)).toBe(true); + expect(result[0].tags.length).toBe(0); + }); + + it('should return an empty array if no base sections are found', async () => { + localQuery.mockResolvedValueOnce([]); + const result = await PlanSectionProgress.findByPlanId('ref', context, 123); + expect(result).toEqual([]); + }); + + it('should set correct sectionType and IDs for custom and base sections', async () => { + const baseSection = { versionedSectionId: 1, displayOrder: 0, title: 'Base', totalQuestions: 2, answeredQuestions: 1, tags: [] }; + const customSection = { id: 10, name: 'Custom', pinnedSectionType: 'BASE', pinnedSectionId: 1, totalQuestions: 3 }; + localQuery.mockResolvedValueOnce([baseSection]); // base sections + + /*eslint-disable @typescript-eslint/no-explicit-any */ + jest.spyOn(PlanSectionProgress as any, 'findTemplateCustomizationId').mockResolvedValue(99); + jest.spyOn(PlanSectionProgress as any, 'fetchCustomSections').mockResolvedValue([customSection]); + jest.spyOn(PlanSectionProgress as any, 'fetchExtraQuestionsForBaseSections').mockResolvedValue([]); + jest.spyOn(PlanSectionProgress as any, 'fetchAnsweredCustomQuestions').mockResolvedValue([]); + + const result = await PlanSectionProgress.findByPlanId('ref', context, 123, 456); + expect(result[0].sectionType).toBeDefined(); + expect(result[0].versionedSectionId).toBe(1); + expect(result[1].sectionType).toBeDefined(); + expect(result[1].customSectionId).toBe(10); + }); + + it('should handle circular pinning gracefully (no infinite recursion)', async () => { + const baseSection = { versionedSectionId: 1, displayOrder: 0, title: 'Base', totalQuestions: 2, answeredQuestions: 1, tags: [] }; + const customSection1 = { id: 10, name: 'Custom1', pinnedSectionType: 'BASE', pinnedSectionId: 1, totalQuestions: 3 }; + // Circular: customSection2 pinned to customSection1, customSection1 pinned to customSection2 + const customSection2 = { id: 11, name: 'Custom2', pinnedSectionType: 'CUSTOM', pinnedSectionId: 10, totalQuestions: 2 }; + // Now, customSection1 is also pinned to customSection2 (circular) + customSection1.pinnedSectionId = 11; + localQuery.mockResolvedValueOnce([baseSection]); // base sections + + /*eslint-disable @typescript-eslint/no-explicit-any */ + jest.spyOn(PlanSectionProgress as any, 'findTemplateCustomizationId').mockResolvedValue(99); + jest.spyOn(PlanSectionProgress as any, 'fetchCustomSections').mockResolvedValue([customSection1, customSection2]); + jest.spyOn(PlanSectionProgress as any, 'fetchExtraQuestionsForBaseSections').mockResolvedValue([]); + jest.spyOn(PlanSectionProgress as any, 'fetchAnsweredCustomQuestions').mockResolvedValue([]); + + const result = await PlanSectionProgress.findByPlanId('ref', context, 123, 456); + // Should not hang, should return base + both customs at most + expect(result.length).toBeGreaterThanOrEqual(1); + expect(result.length).toBeLessThanOrEqual(3); + }); }); + describe('PlanProgress', () => { let progress; @@ -265,55 +480,69 @@ describe('PlanProgress', () => { }); describe('PlanProgress.findByPlanId', () => { - const originalQuery = Plan.query; - - let localQuery; - let progress; + let mockFindSectionProgress: jest.SpyInstance; beforeEach(() => { - localQuery = jest.fn(); - (Plan.query as jest.Mock) = localQuery; - - progress = new PlanProgress({ - totalQuestions: casual.integer(50, 100), - answeredQuestions: casual.integer(0, 50) - }); + mockFindSectionProgress = jest.spyOn(PlanSectionProgress, 'findByPlanId'); }); afterEach(() => { - Plan.query = originalQuery; + jest.restoreAllMocks(); }); - it('should call the correct SQL query', async () => { - localQuery.mockResolvedValueOnce([progress]); + it('should delegate to PlanSectionProgress.findByPlanId with the correct arguments', async () => { + mockFindSectionProgress.mockResolvedValueOnce([]); const planId = casual.integer(1, 99); - const sql = `SELECT COUNT(DISTINCT vq.id) AS totalQuestions, - COUNT(DISTINCT CASE - WHEN a.id IS NOT NULL AND NULLIF(TRIM(a.json), '') IS NOT NULL - THEN vq.id - END) AS answeredQuestions - FROM plans p - JOIN versionedTemplates vt ON vt.id = p.versionedTemplateId - JOIN versionedSections vs ON vs.versionedTemplateId = vt.id - JOIN versionedQuestions vq ON vq.versionedSectionId = vs.id - LEFT JOIN answers a - ON a.planId = p.id - AND a.versionedQuestionId = vq.id - WHERE p.id = ?; -` - const result = await PlanProgress.findByPlanId('testing', context, planId); - expect(localQuery).toHaveBeenCalledTimes(1); - expect(localQuery).toHaveBeenLastCalledWith(context, sql, [planId.toString()], 'testing') - expect(result).toEqual(progress); + const versionedTemplateId = casual.integer(1, 99); + + await PlanProgress.findByPlanId('testing', context, planId, versionedTemplateId); + + expect(mockFindSectionProgress).toHaveBeenCalledTimes(1); + expect(mockFindSectionProgress).toHaveBeenCalledWith('testing', context, planId, versionedTemplateId); }); - it('should return 0 if no questions are found', async () => { - progress = new PlanProgress({ - totalQuestions: 0, - answeredQuestions: 0 - }); - localQuery.mockResolvedValueOnce([]); - expect(progress.percentComplete).toEqual(0); + it('should return null if no sections are found', async () => { + mockFindSectionProgress.mockResolvedValueOnce([]); + const result = await PlanProgress.findByPlanId('testing', context, casual.integer(1, 99)); + expect(result).toBeNull(); + }); + + it('should aggregate totalQuestions and answeredQuestions across all sections', async () => { + const sections = [ + new PlanSectionProgress({ versionedSectionId: 1, title: 'S1', displayOrder: 0, totalQuestions: 5, answeredQuestions: 3 }), + new PlanSectionProgress({ versionedSectionId: 2, title: 'S2', displayOrder: 1, totalQuestions: 10, answeredQuestions: 7 }), + ]; + mockFindSectionProgress.mockResolvedValueOnce(sections); + + const result = await PlanProgress.findByPlanId('testing', context, casual.integer(1, 99)); + + expect(result).toBeInstanceOf(PlanProgress); + expect(result.totalQuestions).toBe(15); + expect(result.answeredQuestions).toBe(10); + }); + + it('should calculate percentComplete correctly', async () => { + const sections = [ + new PlanSectionProgress({ versionedSectionId: 1, title: 'S1', displayOrder: 0, totalQuestions: 4, answeredQuestions: 1 }), + new PlanSectionProgress({ versionedSectionId: 2, title: 'S2', displayOrder: 1, totalQuestions: 6, answeredQuestions: 4 }), + ]; + mockFindSectionProgress.mockResolvedValueOnce(sections); + + const result = await PlanProgress.findByPlanId('testing', context, casual.integer(1, 99)); + + expect(result.percentComplete).toBe(50.0); // 5/10 * 100 + }); + + it('should return 0 percentComplete when totalQuestions is 0', async () => { + const sections = [ + new PlanSectionProgress({ versionedSectionId: 1, title: 'S1', displayOrder: 0, totalQuestions: 0, answeredQuestions: 0 }), + ]; + mockFindSectionProgress.mockResolvedValueOnce(sections); + + const result = await PlanProgress.findByPlanId('testing', context, casual.integer(1, 99)); + + expect(result).toBeInstanceOf(PlanProgress); + expect(result.percentComplete).toBe(0); }); }); @@ -457,7 +686,7 @@ describe('Plan.processResult', () => { plan.update = mockUpdate; const newDmpId = getMockDMPId(); - const updatedPlan = new Plan({...plan, dmpId: newDmpId}); + const updatedPlan = new Plan({ ...plan, dmpId: newDmpId }); mockGenerateDMPId.mockResolvedValueOnce(newDmpId); mockUpdate.mockResolvedValueOnce(updatedPlan); @@ -477,7 +706,7 @@ describe('Plan.processResult', () => { plan.update = mockUpdate; const newDmpId = getMockDMPId(); - const updatedPlan = new Plan({...plan, dmpId: newDmpId}); + const updatedPlan = new Plan({ ...plan, dmpId: newDmpId }); mockGenerateDMPId.mockResolvedValueOnce(newDmpId); mockUpdate.mockResolvedValueOnce(updatedPlan); diff --git a/src/models/__tests__/TemplateCustomization.spec.ts b/src/models/__tests__/TemplateCustomization.spec.ts index 56b28b11..0f77d8d3 100644 --- a/src/models/__tests__/TemplateCustomization.spec.ts +++ b/src/models/__tests__/TemplateCustomization.spec.ts @@ -4,8 +4,8 @@ import { TemplateCustomizationStatus, TemplateCustomizationOverview, } from '../TemplateCustomization'; -import { VersionedTemplate } from '../VersionedTemplate'; import { VersionedTemplateCustomization } from '../VersionedTemplateCustomization'; +import * as publishHelpers from '../../services/templateCustomizationPublishHelpers'; import { MyContext } from '../../context'; describe('TemplateCustomization', () => { @@ -129,18 +129,18 @@ describe('TemplateCustomization', () => { }); }); - describe('publish()', () => { + describe('unpublish()', () => { it('should add error when id is not set', async () => { const customization = new TemplateCustomization({ affiliationId: 'affil-123', templateId: 1, currentVersionedTemplateId: 10, - status: 'DRAFT', + status: 'PUBLISHED', migrationStatus: 'OK', errors: {} }); - const result = await customization.publish(mockContext); + const result = await customization.unpublish(mockContext); expect(result.errors.general).toBe('Customization has never been saved'); }); @@ -151,287 +151,280 @@ describe('TemplateCustomization', () => { affiliationId: null, templateId: 1, currentVersionedTemplateId: 10, - status: 'DRAFT', + status: 'PUBLISHED', migrationStatus: 'OK', errors: {} }); - const result = await customization.publish(mockContext); + const result = await customization.unpublish(mockContext); expect(result.errors.affiliationId).toBe('Affiliation can\'t be blank'); }); - it('should add error when the customization is already published', async () => { + it('should add error when the customization is not published', async () => { const customization = new TemplateCustomization({ id: 1, affiliationId: null, templateId: 1, currentVersionedTemplateId: 10, - status: 'PUBLISHED', - latestPublishedVersionId: 100, - latestPublishedDate: '2026-01-01 12:13:14', + status: 'DRAFT', + latestPublishedDate: null, + latestPublishedVersionId: null, migrationStatus: 'OK', errors: {} }); - const result = await customization.publish(mockContext); + const result = await customization.unpublish(mockContext); - expect(result.errors.general).toBe('Customization is already published!'); + expect(result.errors.general).toBe('Customization is not published!'); }); - it('should add error when drift is detected', async () => { + it('should return as-is when versioned customization is not found', async () => { const customization = new TemplateCustomization({ id: 1, affiliationId: 'affil-123', templateId: 1, currentVersionedTemplateId: 10, - latestPublishedVersionId: null, - status: 'DRAFT', + latestPublishedVersionId: 100, + status: 'PUBLISHED', migrationStatus: 'OK', errors: {} }); - const mockTemplate = { id: 15 } as undefined as VersionedTemplate; - jest.spyOn(VersionedTemplate, 'findActiveByTemplateId').mockResolvedValue(mockTemplate); + const findBySpy = jest.spyOn(VersionedTemplateCustomization, 'findById').mockResolvedValue(null); - const result = await customization.publish(mockContext); + const result = await customization.unpublish(mockContext); - expect(result.errors.general).toBe('Unable to create version'); + expect(findBySpy).toHaveBeenCalledWith( + 'TemplateCustomization.unpublish', + mockContext, + 100 + ); + expect(result).toBeInstanceOf(TemplateCustomization); }); - it('should add error when versioned customization creation fails', async () => { + it('should add error when versioned customization update fails', async () => { const customization = new TemplateCustomization({ id: 1, affiliationId: 'affil-123', templateId: 1, currentVersionedTemplateId: 10, - status: 'DRAFT', + latestPublishedVersionId: 100, + status: 'PUBLISHED', migrationStatus: 'OK', errors: {} }); - const mockTemplate = { id: 10 } as undefined as VersionedTemplate; - const createSpy = jest.spyOn(VersionedTemplateCustomization.prototype, 'create').mockResolvedValue(null); - jest.spyOn(VersionedTemplate, 'findActiveByTemplateId').mockResolvedValue(mockTemplate); + const mockVersionedCustomization = { + id: 100, + active: true, + update: jest.fn().mockResolvedValue(null) + } as undefined as VersionedTemplateCustomization; + jest.spyOn(VersionedTemplateCustomization, 'findById').mockResolvedValue(mockVersionedCustomization); - await customization.publish(mockContext); + const result = await customization.unpublish(mockContext); - expect(createSpy).toHaveBeenCalled(); + expect(mockVersionedCustomization.update).toHaveBeenCalledWith(mockContext, false); + expect(result.errors.general).toBe('Unable to unpublish'); }); - it('should add error when update fails after successful version creation', async () => { + it('should add error when customization update fails after version deactivation', async () => { const customization = new TemplateCustomization({ id: 1, affiliationId: 'affil-123', templateId: 1, currentVersionedTemplateId: 10, - status: 'DRAFT', + latestPublishedVersionId: 100, + status: 'PUBLISHED', migrationStatus: 'OK', errors: {} }); const mockVersionedCustomization = { id: 100, - created: new Date() + active: true, + update: jest.fn().mockResolvedValue({ id: 100 }) } as undefined as VersionedTemplateCustomization; - const mockTemplate = { id: 10 } as undefined as VersionedTemplate; - jest.spyOn(VersionedTemplate, 'findActiveByTemplateId').mockResolvedValue(mockTemplate); - jest.spyOn(VersionedTemplateCustomization.prototype, 'create').mockResolvedValue(new VersionedTemplateCustomization(mockVersionedCustomization)); + jest.spyOn(VersionedTemplateCustomization, 'findById').mockResolvedValue(mockVersionedCustomization); customization.update = jest.fn().mockResolvedValue(null); - const result = await customization.publish(mockContext); + const result = await customization.unpublish(mockContext); - expect(result.errors.general).toBe('Unable to publish'); + expect(result.errors.general).toBe('Unable to unpublish the customization'); }); - it('should successfully publish customization', async () => { + it('should successfully unpublish customization', async () => { const customization = new TemplateCustomization({ id: 1, affiliationId: 'affil-123', templateId: 1, currentVersionedTemplateId: 10, - status: 'DRAFT', + latestPublishedVersionId: 100, + status: 'PUBLISHED', migrationStatus: 'OK', errors: {} }); + const mockUpdatedCustomization = new TemplateCustomization({ + ...customization, + status: TemplateCustomizationStatus.DRAFT + }); const mockVersionedCustomization = { id: 100, - created: new Date() + active: true, + update: jest.fn().mockResolvedValue({ id: 100 }) } as undefined as VersionedTemplateCustomization; - const mockUpdatedCustomization = new TemplateCustomization({ - ...customization, - status: TemplateCustomizationStatus.PUBLISHED - }) as undefined as TemplateCustomization; - const mockTemplate = { id: 10 } as undefined as VersionedTemplate; - jest.spyOn(VersionedTemplate, 'findActiveByTemplateId').mockResolvedValue(mockTemplate); - jest.spyOn(VersionedTemplateCustomization.prototype, 'create').mockResolvedValue(new VersionedTemplateCustomization(mockVersionedCustomization)); + jest.spyOn(VersionedTemplateCustomization, 'findById').mockResolvedValue(mockVersionedCustomization); customization.update = jest.fn().mockResolvedValue(mockUpdatedCustomization); - const result = await customization.publish(mockContext); + const result = await customization.unpublish(mockContext); - expect(customization.status).toBe(TemplateCustomizationStatus.PUBLISHED); + expect(customization.status).toBe(TemplateCustomizationStatus.DRAFT); expect(customization.isDirty).toBe(false); - expect(customization.latestPublishedVersionId).toBe(100); - expect(result).toBeInstanceOf(TemplateCustomization); + expect(customization.latestPublishedVersionId).toBeUndefined(); + expect(customization.latestPublishedDate).toBeUndefined(); + expect(result).toBe(mockUpdatedCustomization); }); }); - describe('unpublish()', () => { + describe('publish()', () => { it('should add error when id is not set', async () => { const customization = new TemplateCustomization({ affiliationId: 'affil-123', templateId: 1, currentVersionedTemplateId: 10, - status: 'PUBLISHED', + status: 'DRAFT', migrationStatus: 'OK', errors: {} }); - const result = await customization.unpublish(mockContext); + const result = await customization.publish(mockContext); expect(result.errors.general).toBe('Customization has never been saved'); }); - it('should add error when validation fails', async () => { + it('should add error when already published and not dirty', async () => { const customization = new TemplateCustomization({ id: 1, - affiliationId: null, + affiliationId: 'affil-123', templateId: 1, currentVersionedTemplateId: 10, status: 'PUBLISHED', + isDirty: false, migrationStatus: 'OK', errors: {} }); - const result = await customization.unpublish(mockContext); + const result = await customization.publish(mockContext); - expect(result.errors.affiliationId).toBe('Affiliation can\'t be blank'); + expect(result.errors.general).toBe('Customization is already published!'); }); - it('should add error when the customization is not published', async () => { + it('should add error when validation fails', async () => { const customization = new TemplateCustomization({ id: 1, affiliationId: null, templateId: 1, currentVersionedTemplateId: 10, status: 'DRAFT', - latestPublishedDate: null, - latestPublishedVersionId: null, migrationStatus: 'OK', errors: {} }); - const result = await customization.unpublish(mockContext); + const result = await customization.publish(mockContext); - expect(result.errors.general).toBe('Customization is not published!'); + expect(result.errors.affiliationId).toBeDefined(); }); - it('should return as-is when versioned customization is not found', async () => { + it('should return errors when version creation fails', async () => { const customization = new TemplateCustomization({ id: 1, affiliationId: 'affil-123', templateId: 1, currentVersionedTemplateId: 10, - latestPublishedVersionId: 100, - status: 'PUBLISHED', + status: 'DRAFT', migrationStatus: 'OK', errors: {} }); - const findBySpy = jest.spyOn(VersionedTemplateCustomization, 'findById').mockResolvedValue(null); + const mockFailed = new VersionedTemplateCustomization({ errors: { general: 'DB error' } }); + jest.spyOn(VersionedTemplateCustomization.prototype, 'create').mockResolvedValue(mockFailed); - const result = await customization.unpublish(mockContext); + const result = await customization.publish(mockContext); - expect(findBySpy).toHaveBeenCalledWith( - 'TemplateCustomization.unpublish', - mockContext, - 100 - ); - expect(result).toBeInstanceOf(TemplateCustomization); + expect(result.errors.general).toBe('DB error'); }); - it('should add error when versioned customization update fails', async () => { + it('should rollback and return errors when snapshotting children fails', async () => { const customization = new TemplateCustomization({ id: 1, affiliationId: 'affil-123', templateId: 1, currentVersionedTemplateId: 10, - latestPublishedVersionId: 100, - status: 'PUBLISHED', + status: 'DRAFT', migrationStatus: 'OK', errors: {} }); - const mockVersionedCustomization = { - id: 100, - active: true, - update: jest.fn().mockResolvedValue(null) - } as undefined as VersionedTemplateCustomization; - jest.spyOn(VersionedTemplateCustomization, 'findById').mockResolvedValue(mockVersionedCustomization); + const mockCreated = new VersionedTemplateCustomization({ id: 99 }); + jest.spyOn(VersionedTemplateCustomization.prototype, 'create').mockResolvedValue(mockCreated); + jest.spyOn(publishHelpers, 'snapshotCustomizationChildren').mockImplementation( + async (_ref, _ctx, custObj) => { + custObj.addError('general', 'snapshot failed'); + } + ); + const rollbackSpy = jest.spyOn(publishHelpers, 'rollbackPublishedSnapshot').mockResolvedValue(); - const result = await customization.unpublish(mockContext); + const result = await customization.publish(mockContext); - expect(mockVersionedCustomization.update).toHaveBeenCalledWith(mockContext, false); - expect(result.errors.general).toBe('Unable to unpublish'); + expect(rollbackSpy).toHaveBeenCalledWith(mockContext, 99, customization.latestPublishedVersionId); + expect(result.errors.general).toBe('snapshot failed'); }); - it('should add error when customization update fails after version deactivation', async () => { + it('should add error when update fails after successful snapshot', async () => { const customization = new TemplateCustomization({ id: 1, affiliationId: 'affil-123', templateId: 1, currentVersionedTemplateId: 10, - latestPublishedVersionId: 100, - status: 'PUBLISHED', + status: 'DRAFT', migrationStatus: 'OK', errors: {} }); - const mockVersionedCustomization = { - id: 100, - active: true, - update: jest.fn().mockResolvedValue({id: 100}) - } as undefined as VersionedTemplateCustomization; - jest.spyOn(VersionedTemplateCustomization, 'findById').mockResolvedValue(mockVersionedCustomization); + const mockCreated = new VersionedTemplateCustomization({ id: 99 }); + jest.spyOn(VersionedTemplateCustomization.prototype, 'create').mockResolvedValue(mockCreated); + jest.spyOn(publishHelpers, 'snapshotCustomizationChildren').mockResolvedValue(); customization.update = jest.fn().mockResolvedValue(null); - const result = await customization.unpublish(mockContext); + const result = await customization.publish(mockContext); - expect(result.errors.general).toBe('Unable to unpublish the customization'); + expect(result.errors.general).toBe('Unable to publish'); }); - it('should successfully unpublish customization', async () => { + it('should successfully publish customization', async () => { const customization = new TemplateCustomization({ id: 1, affiliationId: 'affil-123', templateId: 1, currentVersionedTemplateId: 10, - latestPublishedVersionId: 100, - status: 'PUBLISHED', + status: 'DRAFT', migrationStatus: 'OK', errors: {} }); - const mockUpdatedCustomization = new TemplateCustomization({ - ...customization, - status: TemplateCustomizationStatus.DRAFT - }); - const mockVersionedCustomization = { - id: 100, - active: true, - update: jest.fn().mockResolvedValue({id: 100}) - } as undefined as VersionedTemplateCustomization; - jest.spyOn(VersionedTemplateCustomization, 'findById').mockResolvedValue(mockVersionedCustomization); - customization.update = jest.fn().mockResolvedValue(mockUpdatedCustomization); + const mockCreated = new VersionedTemplateCustomization({ id: 99, created: new Date('2025-01-01') }); + jest.spyOn(VersionedTemplateCustomization.prototype, 'create').mockResolvedValue(mockCreated); + jest.spyOn(publishHelpers, 'snapshotCustomizationChildren').mockResolvedValue(); + const mockUpdated = new TemplateCustomization({ ...customization, status: TemplateCustomizationStatus.PUBLISHED }); + customization.update = jest.fn().mockResolvedValue(mockUpdated); - const result = await customization.unpublish(mockContext); + const result = await customization.publish(mockContext); - expect(customization.status).toBe(TemplateCustomizationStatus.DRAFT); + expect(customization.status).toBe(TemplateCustomizationStatus.PUBLISHED); expect(customization.isDirty).toBe(false); - expect(customization.latestPublishedVersionId).toBeUndefined(); - expect(customization.latestPublishedDate).toBeUndefined(); - expect(result).toBe(mockUpdatedCustomization); + expect(customization.latestPublishedVersionId).toBe(99); + expect(result.errors).toEqual({}); }); }); @@ -461,7 +454,7 @@ describe('TemplateCustomization', () => { errors: {} }); - const mockExisting = new TemplateCustomization({id: 100}); + const mockExisting = new TemplateCustomization({ id: 100 }); const findFirstSpy = jest.spyOn(TemplateCustomization, 'findByAffiliationAndTemplate').mockResolvedValue(mockExisting); const result = await customization.create(mockContext); @@ -485,7 +478,7 @@ describe('TemplateCustomization', () => { errors: {} }); - const mockCreated = new TemplateCustomization({id: 100, ...customization}); + const mockCreated = new TemplateCustomization({ id: 100, ...customization }); jest.spyOn(TemplateCustomization, 'findByAffiliationAndTemplate').mockResolvedValue(null); const findBySpy = jest.spyOn(TemplateCustomization, 'findById').mockResolvedValue(mockCreated); const insertSpy = jest.spyOn(TemplateCustomization, 'insert').mockResolvedValue(100); diff --git a/src/models/__tests__/VersionedCustomQuestion.spec.ts b/src/models/__tests__/VersionedCustomQuestion.spec.ts index bd3bcc44..c2ce8842 100644 --- a/src/models/__tests__/VersionedCustomQuestion.spec.ts +++ b/src/models/__tests__/VersionedCustomQuestion.spec.ts @@ -284,7 +284,7 @@ describe("VersionedCustomQuestion", () => { { id: 1, versionedTemplateCustomizationId: 100, - customQuestionId: 200, + customQuestionId: 200, versionedSectionType: PinnedSectionTypeEnum.BASE, versionedSectionId: 300, json: { @@ -796,4 +796,122 @@ describe("VersionedCustomQuestion", () => { expect(result).toBeUndefined(); }); }); + + describe("findByVersionedCustomSectionId", () => { + it("should find VersionedCustomQuestions by versioned custom section id", async () => { + const mockQuery = jest.spyOn(MySqlModel, "query").mockResolvedValue([ + { + id: 1, + versionedTemplateCustomizationId: 100, + customQuestionId: 200, + versionedSectionType: PinnedSectionTypeEnum.CUSTOM, + versionedSectionId: 300, + pinnedVersionedQuestionType: PinnedQuestionTypeEnum.CUSTOM, + pinnedVersionedQuestionId: 400, + json: JSON.stringify({ + type: "text", + attributes: { maxLength: 100 }, + meta: { schemaVersion: "1.0" } + }), + questionText: "Test Question", + }, + ]); + + const result = await VersionedCustomQuestion.findByVersionedCustomSectionId( + "test.ref", + mockContext, + 300 + ); + + expect(mockQuery).toHaveBeenCalledWith( + mockContext, + expect.stringContaining("SELECT vcq.* FROM versionedCustomQuestions as vcq"), + ["300"], + "test.ref" + ); + expect(mockQuery).toHaveBeenCalledWith( + mockContext, + expect.stringContaining("vtc.active = 1"), + ["300"], + "test.ref" + ); + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBe(1); + expect(result[0]).toBeInstanceOf(VersionedCustomQuestion); + expect(result[0].versionedSectionType).toBe("CUSTOM"); + expect(result[0].versionedSectionId).toBe(300); + }); + + it("should return an empty array when not found", async () => { + jest.spyOn(MySqlModel, "query").mockResolvedValue([]); + + const result = await VersionedCustomQuestion.findByVersionedCustomSectionId( + "test.ref", + mockContext, + 999 + ); + + expect(result).toEqual([]); + }); + }); + + describe("findByVersionedSectionIdAndType", () => { + it("should find VersionedCustomQuestions by section id and type", async () => { + const mockQuery = jest.spyOn(MySqlModel, "query").mockResolvedValue([ + { + id: 1, + versionedTemplateCustomizationId: 100, + customQuestionId: 200, + versionedSectionType: PinnedSectionTypeEnum.BASE, + versionedSectionId: 300, + pinnedVersionedQuestionType: PinnedQuestionTypeEnum.CUSTOM, + pinnedVersionedQuestionId: 400, + json: JSON.stringify({ + type: "text", + attributes: { maxLength: 100 }, + meta: { schemaVersion: "1.0" } + }), + questionText: "Test Question", + }, + ]); + + const result = await VersionedCustomQuestion.findByVersionedSectionIdAndType( + "test.ref", + mockContext, + 300, + "BASE" + ); + + expect(mockQuery).toHaveBeenCalledWith( + mockContext, + expect.stringContaining("SELECT vcq.* FROM versionedCustomQuestions as vcq"), + ["BASE", "300"], + "test.ref" + ); + expect(mockQuery).toHaveBeenCalledWith( + mockContext, + expect.stringContaining("vtc.active = 1"), + ["BASE", "300"], + "test.ref" + ); + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBe(1); + expect(result[0]).toBeInstanceOf(VersionedCustomQuestion); + expect(result[0].versionedSectionType).toBe("BASE"); + expect(result[0].versionedSectionId).toBe(300); + }); + + it("should return an empty array when not found", async () => { + jest.spyOn(MySqlModel, "query").mockResolvedValue([]); + + const result = await VersionedCustomQuestion.findByVersionedSectionIdAndType( + "test.ref", + mockContext, + 999, + "CUSTOM" + ); + + expect(result).toEqual([]); + }); + }); }); diff --git a/src/models/__tests__/VersionedQuestionCustomization.spec.ts b/src/models/__tests__/VersionedQuestionCustomization.spec.ts index 34be4797..1c5d1a9d 100644 --- a/src/models/__tests__/VersionedQuestionCustomization.spec.ts +++ b/src/models/__tests__/VersionedQuestionCustomization.spec.ts @@ -510,4 +510,72 @@ describe("VersionedQuestionCustomization", () => { expect(result).toEqual([]); }); }); + + describe("findActiveByTemplateAffiliationAndQuestion", () => { + const expectedSql = `SELECT vqc.*, vtc.affiliationId as customizationAffiliationId + FROM versionedQuestionCustomizations AS vqc + JOIN versionedTemplateCustomizations AS vtc + ON vqc.versionedTemplateCustomizationId = vtc.id + WHERE vtc.active = 1 + AND vtc.affiliationId = ? + AND vqc.versionedQuestionId = ? + LIMIT 1`; + + it("should find the active VersionedQuestionCustomization by affiliation and question", async () => { + const mockQuery = jest.spyOn(MySqlModel, "query").mockResolvedValue([ + { + id: 1, + versionedTemplateCustomizationId: 100, + questionCustomizationId: 200, + versionedQuestionId: 300, + guidanceText: "Test guidance", + }, + ]); + + const result = await VersionedQuestionCustomization.findActiveByTemplateAffiliationAndQuestion( + "test.ref", + mockContext, + "affil-123", + 300 + ); + + expect(mockQuery).toHaveBeenCalledWith( + mockContext, + expectedSql, + ["affil-123", "300"], + "test.ref" + ); + expect(result).toBeInstanceOf(VersionedQuestionCustomization); + expect(result.id).toBe(1); + expect(result.versionedTemplateCustomizationId).toBe(100); + expect(result.versionedQuestionId).toBe(300); + expect(result.guidanceText).toBe("Test guidance"); + }); + + it("should return undefined when not found", async () => { + jest.spyOn(MySqlModel, "query").mockResolvedValue([]); + + const result = await VersionedQuestionCustomization.findActiveByTemplateAffiliationAndQuestion( + "test.ref", + mockContext, + "affil-123", + 300 + ); + + expect(result).toBeUndefined(); + }); + + it("should return undefined when query result is not an array", async () => { + jest.spyOn(MySqlModel, "query").mockResolvedValue(null); + + const result = await VersionedQuestionCustomization.findActiveByTemplateAffiliationAndQuestion( + "test.ref", + mockContext, + "affil-123", + 300 + ); + + expect(result).toBeUndefined(); + }); + }); }); diff --git a/src/models/__tests__/VersionedSectionCustomization.spec.ts b/src/models/__tests__/VersionedSectionCustomization.spec.ts index 6ec94443..0e33b0cb 100644 --- a/src/models/__tests__/VersionedSectionCustomization.spec.ts +++ b/src/models/__tests__/VersionedSectionCustomization.spec.ts @@ -499,4 +499,71 @@ describe("VersionedSectionCustomization", () => { expect(result).toEqual([]); }); }); + + describe("findActiveByTemplateAffiliationAndSection", () => { + const expectedSql = `SELECT vsc.* FROM versionedSectionCustomizations AS vsc + JOIN versionedTemplateCustomizations AS vtc + ON vsc.versionedTemplateCustomizationId = vtc.id + WHERE vtc.active = 1 + AND vtc.affiliationId = ? + AND vsc.versionedSectionId = ? + LIMIT 1`; + + it("should find the active VersionedSectionCustomization by affiliation and section", async () => { + const mockQuery = jest.spyOn(MySqlModel, "query").mockResolvedValue([ + { + id: 1, + versionedTemplateCustomizationId: 100, + sectionCustomizationId: 200, + versionedSectionId: 300, + guidance: "Test guidance", + }, + ]); + + const result = await VersionedSectionCustomization.findActiveByTemplateAffiliationAndSection( + "test.ref", + mockContext, + "affil-123", + 300 + ); + + expect(mockQuery).toHaveBeenCalledWith( + mockContext, + expectedSql, + ["affil-123", "300"], + "test.ref" + ); + expect(result).toBeInstanceOf(VersionedSectionCustomization); + expect(result.id).toBe(1); + expect(result.versionedTemplateCustomizationId).toBe(100); + expect(result.versionedSectionId).toBe(300); + expect(result.guidance).toBe("Test guidance"); + }); + + it("should return undefined when not found", async () => { + jest.spyOn(MySqlModel, "query").mockResolvedValue([]); + + const result = await VersionedSectionCustomization.findActiveByTemplateAffiliationAndSection( + "test.ref", + mockContext, + "affil-123", + 300 + ); + + expect(result).toBeUndefined(); + }); + + it("should return undefined when query result is not an array", async () => { + jest.spyOn(MySqlModel, "query").mockResolvedValue(null); + + const result = await VersionedSectionCustomization.findActiveByTemplateAffiliationAndSection( + "test.ref", + mockContext, + "affil-123", + 300 + ); + + expect(result).toBeUndefined(); + }); + }); }); diff --git a/src/models/__tests__/VersionedTemplate.spec.ts b/src/models/__tests__/VersionedTemplate.spec.ts index ca48a7c2..d48a776f 100644 --- a/src/models/__tests__/VersionedTemplate.spec.ts +++ b/src/models/__tests__/VersionedTemplate.spec.ts @@ -85,14 +85,21 @@ describe('VersionedTemplateSearchResult', () => { const term = versionedTemplateSearchResult.name.split(0, 5)[0]; const result = await VersionedTemplateSearchResult.search('Test', context, term); - const sql = 'SELECT vt.id, vt.templateId, vt.name, vt.description, vt.version, vt.visibility, vt.bestPractice, \ - vt.modified, vt.modifiedById, TRIM(CONCAT(u.givenName, CONCAT(\' \', u.surName))) as modifiedByName, \ - a.id as ownerId, vt.ownerId as ownerURI, a.displayName as ownerDisplayName, \ - a.searchName as ownerSearchName \ - FROM versionedTemplates vt \ - LEFT JOIN users u ON u.id = vt.modifiedById \ - LEFT JOIN affiliations a ON a.uri = vt.ownerId'; - const vals = [TemplateVersionType.PUBLISHED, `%${term.toLowerCase()}%`, `%${term.toLowerCase()}%`]; + const affiliationId = context.token.affiliationId; + const sql = + 'SELECT vt.id, vt.templateId, vt.name, vt.description, vt.version, vt.visibility, vt.bestPractice, ' + + 'vt.modified, vt.modifiedById, TRIM(CONCAT(u.givenName, " ", u.surName)) as modifiedByName, ' + + 'a.id as ownerId, vt.ownerId as ownerURI, a.displayName as ownerDisplayName, ' + + 'a.searchName as ownerSearchName, ' + + 'vtc.id as versionedTemplateCustomizationId ' + + 'FROM versionedTemplates vt ' + + 'LEFT JOIN users u ON u.id = vt.modifiedById ' + + 'LEFT JOIN affiliations a ON a.uri = vt.ownerId ' + + 'LEFT JOIN versionedTemplateCustomizations vtc ' + + 'ON vtc.currentVersionedTemplateId = vt.id ' + + 'AND vtc.affiliationId=? ' + + 'AND vtc.active = 1'; + const vals = [affiliationId, TemplateVersionType.PUBLISHED, `%${term.toLowerCase()}%`, `%${term.toLowerCase()}%`]; const whereFilters = ['vt.active = 1 AND vt.versionType = ?', '(LOWER(vt.name) LIKE ? OR LOWER(a.searchName) LIKE ?)']; diff --git a/src/resolvers/__tests__/guidance.spec.ts b/src/resolvers/__tests__/guidance.spec.ts new file mode 100644 index 00000000..f8a040f3 --- /dev/null +++ b/src/resolvers/__tests__/guidance.spec.ts @@ -0,0 +1,594 @@ +import casual from "casual"; + +// Mock the authenticatedResolver HOF before importing resolvers +jest.mock('../../services/authService', () => ({ + ...jest.requireActual('../../services/authService'), + authenticatedResolver: jest.fn((ref, level, resolver) => resolver), +})); + +import { ApolloServer } from "@apollo/server"; +import { typeDefs } from "../../schema"; +import { resolvers } from '../../resolver'; + +import { logger } from "../../logger"; +import { JWTAccessToken } from "../../services/tokenService"; +import { Guidance } from '../../models/Guidance'; +import { GuidanceGroup } from '../../models/GuidanceGroup'; +import { Plan } from '../../models/Plan'; +import { Project } from '../../models/Project'; +import { User, UserRole } from "../../models/User"; +import { + hasPermissionOnGuidanceGroup, + markGuidanceGroupAsDirty, + getGuidanceSourcesForPlan, +} from '../../services/guidanceService'; +import { hasPermissionOnProject } from '../../services/projectService'; +import { buildContext, mockToken } from "../../__mocks__/context"; + +jest.mock('../../context.ts'); +jest.mock('../../datasources/cache'); + +jest.mock('../../models/Guidance'); +jest.mock('../../models/GuidanceGroup'); +jest.mock('../../models/Plan', () => ({ + Plan: { findById: jest.fn() }, +})); +jest.mock('../../models/Project', () => ({ + Project: { findById: jest.fn() }, +})); +jest.mock('../../services/guidanceService'); +jest.mock('../../services/projectService'); + +let testServer: ApolloServer; +let affiliationId: string; +let adminToken: JWTAccessToken; +let researcherToken: JWTAccessToken; +let query: string; + +async function executeQuery( + query: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + variables: any, + token: JWTAccessToken + // eslint-disable-next-line @typescript-eslint/no-explicit-any +): Promise { + const context = buildContext(logger, token, null); + return await testServer.executeOperation( + { query, variables }, + { contextValue: context }, + ); +} + +beforeEach(async () => { + jest.resetAllMocks(); + + testServer = new ApolloServer({ typeDefs, resolvers }); + + affiliationId = casual.url; + + adminToken = await mockToken(); + adminToken.affiliationId = affiliationId; + adminToken.role = UserRole.ADMIN; + + researcherToken = await mockToken(); + researcherToken.affiliationId = affiliationId; + researcherToken.role = UserRole.RESEARCHER; +}); + +afterEach(() => { + jest.clearAllMocks(); +}); + +describe('guidance resolvers', () => { + let user: User; + + beforeEach(async () => { + user = new User({ + id: casual.integer(1, 999), + givenName: casual.first_name, + surName: casual.last_name, + role: UserRole.ADMIN, + affiliationId, + }); + (user.getEmail as jest.Mock) = jest.fn().mockResolvedValue(casual.email); + }); + + // ============================================================================ + // Query: guidanceByGroup + // ============================================================================ + describe('Query.guidanceByGroup', () => { + beforeEach(() => { + query = ` + query guidanceByGroup($guidanceGroupId: Int!) { + guidanceByGroup(guidanceGroupId: $guidanceGroupId) { + id + guidanceGroupId + guidanceText + tagId + } + } + `; + }); + + it('should return guidance items when admin has permission', async () => { + const mockGuidanceItems = [ + { id: 1, guidanceGroupId: 10, guidanceText: 'Test guidance 1', tagId: 1 }, + { id: 2, guidanceGroupId: 10, guidanceText: 'Test guidance 2', tagId: 2 }, + ]; + + (hasPermissionOnGuidanceGroup as jest.Mock).mockResolvedValue(true); + (Guidance.findByGuidanceGroupId as jest.Mock).mockResolvedValue(mockGuidanceItems); + + const result = await executeQuery(query, { guidanceGroupId: 10 }, adminToken); + + expect(result.body.singleResult.errors).toBeUndefined(); + expect(result.body.singleResult.data.guidanceByGroup).toHaveLength(2); + expect(result.body.singleResult.data.guidanceByGroup[0].id).toEqual(1); + expect(result.body.singleResult.data.guidanceByGroup[1].id).toEqual(2); + expect(Guidance.findByGuidanceGroupId).toHaveBeenCalledWith( + 'guidanceByGroup resolver', + expect.any(Object), + 10 + ); + }); + + it('should return guidance items for non-admin when guidance group is published', async () => { + const mockGuidanceItems = [ + { id: 1, guidanceGroupId: 10, guidanceText: 'Published guidance', tagId: 1 }, + ]; + const mockGuidanceGroup = { id: 10, affiliationId, latestPublishedDate: '2025-01-01' }; + + (hasPermissionOnGuidanceGroup as jest.Mock).mockResolvedValue(false); + (GuidanceGroup.findById as jest.Mock).mockResolvedValue(mockGuidanceGroup); + (Guidance.findByGuidanceGroupId as jest.Mock).mockResolvedValue(mockGuidanceItems); + + const result = await executeQuery(query, { guidanceGroupId: 10 }, researcherToken); + + expect(result.body.singleResult.errors).toBeUndefined(); + expect(result.body.singleResult.data.guidanceByGroup).toHaveLength(1); + expect(result.body.singleResult.data.guidanceByGroup[0].guidanceText).toEqual('Published guidance'); + }); + + it('should return Forbidden when non-admin and guidance group is not published', async () => { + const mockGuidanceGroup = { + id: 10, + affiliationId, + latestPublishedDate: null, + latestPublishedVersionId: null, + }; + + (hasPermissionOnGuidanceGroup as jest.Mock).mockResolvedValue(false); + (GuidanceGroup.findById as jest.Mock).mockResolvedValue(mockGuidanceGroup); + + const result = await executeQuery(query, { guidanceGroupId: 10 }, researcherToken); + + expect(result.body.singleResult.errors).toBeDefined(); + expect(result.body.singleResult.errors[0].message).toEqual('Forbidden'); + }); + }); + + // ============================================================================ + // Query: guidance + // ============================================================================ + describe('Query.guidance', () => { + beforeEach(() => { + query = ` + query guidance($guidanceId: Int!) { + guidance(guidanceId: $guidanceId) { + id + guidanceGroupId + guidanceText + tagId + } + } + `; + }); + + it('should return guidance when admin has permission', async () => { + const mockGuidanceItem = { id: 5, guidanceGroupId: 10, guidanceText: 'Test guidance', tagId: 1 }; + const mockGuidanceGroup = { id: 10, affiliationId, latestPublishedDate: '2025-01-01' }; + + (Guidance.findById as jest.Mock).mockResolvedValue(mockGuidanceItem); + (hasPermissionOnGuidanceGroup as jest.Mock).mockResolvedValue(true); + (GuidanceGroup.findById as jest.Mock).mockResolvedValue(mockGuidanceGroup); + + const result = await executeQuery(query, { guidanceId: 5 }, adminToken); + + expect(result.body.singleResult.errors).toBeUndefined(); + expect(result.body.singleResult.data.guidance.id).toEqual(5); + expect(result.body.singleResult.data.guidance.guidanceText).toEqual('Test guidance'); + }); + + it('should return NotFound when admin has permission but guidance does not exist', async () => { + (Guidance.findById as jest.Mock).mockResolvedValue(null); + (hasPermissionOnGuidanceGroup as jest.Mock).mockResolvedValue(true); + (GuidanceGroup.findById as jest.Mock).mockResolvedValue({ id: 10, affiliationId }); + + const result = await executeQuery(query, { guidanceId: 999 }, adminToken); + + expect(result.body.singleResult.errors).toBeDefined(); + expect(result.body.singleResult.errors[0].message).toEqual('Guidance not found'); + }); + + it('should return guidance for non-admin when guidance group is published', async () => { + const mockGuidanceItem = { id: 5, guidanceGroupId: 10, guidanceText: 'Public guidance', tagId: 1 }; + const mockGuidanceGroup = { id: 10, affiliationId, latestPublishedDate: '2025-01-01' }; + + (Guidance.findById as jest.Mock).mockResolvedValue(mockGuidanceItem); + (hasPermissionOnGuidanceGroup as jest.Mock).mockResolvedValue(false); + (GuidanceGroup.findById as jest.Mock).mockResolvedValue(mockGuidanceGroup); + + const result = await executeQuery(query, { guidanceId: 5 }, researcherToken); + + expect(result.body.singleResult.errors).toBeUndefined(); + expect(result.body.singleResult.data.guidance.id).toEqual(5); + }); + + it('should return Forbidden for non-admin when guidance group is not published', async () => { + const mockGuidanceItem = { id: 5, guidanceGroupId: 10, guidanceText: 'Unpublished', tagId: 1 }; + const mockGuidanceGroup = { + id: 10, + affiliationId, + latestPublishedDate: null, + latestPublishedVersionId: null, + }; + + (Guidance.findById as jest.Mock).mockResolvedValue(mockGuidanceItem); + (hasPermissionOnGuidanceGroup as jest.Mock).mockResolvedValue(false); + (GuidanceGroup.findById as jest.Mock).mockResolvedValue(mockGuidanceGroup); + + const result = await executeQuery(query, { guidanceId: 5 }, researcherToken); + + expect(result.body.singleResult.errors).toBeDefined(); + expect(result.body.singleResult.errors[0].message).toEqual('Forbidden'); + }); + }); + + // ============================================================================ + // Query: guidanceSourcesForPlan + // ============================================================================ + describe('Query.guidanceSourcesForPlan', () => { + beforeEach(() => { + query = ` + query guidanceSourcesForPlan($planId: Int!, $versionedSectionId: Int, $versionedQuestionId: Int, $customSectionId: Int, $customQuestionId: Int) { + guidanceSourcesForPlan(planId: $planId, versionedSectionId: $versionedSectionId, versionedQuestionId: $versionedQuestionId, customSectionId: $customSectionId, customQuestionId: $customQuestionId) { + id + type + label + shortName + orgURI + hasGuidance + items { + id + title + guidanceText + } + } + } + `; + }); + + it('should return guidance sources when user has project permission', async () => { + const mockPlan = { id: 1, projectId: 100 }; + const mockProject = { id: 100 }; + const mockSources = [ + { + id: 'source-1', + type: 'BEST_PRACTICE', + label: 'DMP Tool Best Practices', + shortName: 'DMP Tool', + orgURI: 'https://dmptool.org', + hasGuidance: true, + items: [{ id: 1, title: 'Data Storage', guidanceText: 'Store data securely' }], + }, + ]; + + (Plan.findById as jest.Mock).mockResolvedValue(mockPlan); + (Project.findById as jest.Mock).mockResolvedValue(mockProject); + (hasPermissionOnProject as jest.Mock).mockResolvedValue(true); + (getGuidanceSourcesForPlan as jest.Mock).mockResolvedValue(mockSources); + + const result = await executeQuery( + query, + { planId: 1, versionedSectionId: 5, versionedQuestionId: 10 }, + researcherToken + ); + + expect(result.body.singleResult.errors).toBeUndefined(); + expect(result.body.singleResult.data.guidanceSourcesForPlan).toHaveLength(1); + expect(result.body.singleResult.data.guidanceSourcesForPlan[0].id).toEqual('source-1'); + expect(result.body.singleResult.data.guidanceSourcesForPlan[0].type).toEqual('BEST_PRACTICE'); + expect(result.body.singleResult.data.guidanceSourcesForPlan[0].items[0].guidanceText).toEqual('Store data securely'); + expect(getGuidanceSourcesForPlan).toHaveBeenCalledWith( + expect.any(Object), + 1, + 5, + 10, + undefined, + undefined + ); + }); + + it('should return NotFound when plan does not exist', async () => { + (Plan.findById as jest.Mock).mockResolvedValue(null); + + const result = await executeQuery(query, { planId: 999 }, researcherToken); + + expect(result.body.singleResult.errors).toBeDefined(); + expect(result.body.singleResult.errors[0].message).toEqual('Plan with id 999 not found'); + }); + + it('should return Forbidden when user does not have permission on project', async () => { + const mockPlan = { id: 1, projectId: 100 }; + const mockProject = { id: 100 }; + + (Plan.findById as jest.Mock).mockResolvedValue(mockPlan); + (Project.findById as jest.Mock).mockResolvedValue(mockProject); + (hasPermissionOnProject as jest.Mock).mockResolvedValue(false); + + const result = await executeQuery(query, { planId: 1 }, researcherToken); + + expect(result.body.singleResult.errors).toBeDefined(); + expect(result.body.singleResult.errors[0].message).toEqual('Forbidden'); + }); + + it('should call getGuidanceSourcesForPlan with customSectionId when provided', async () => { + const mockPlan = { id: 1, projectId: 100 }; + const mockProject = { id: 100 }; + + (Plan.findById as jest.Mock).mockResolvedValue(mockPlan); + (Project.findById as jest.Mock).mockResolvedValue(mockProject); + (hasPermissionOnProject as jest.Mock).mockResolvedValue(true); + (getGuidanceSourcesForPlan as jest.Mock).mockResolvedValue([]); + + await executeQuery( + query, + { planId: 1, customSectionId: 7 }, + researcherToken + ); + + expect(getGuidanceSourcesForPlan).toHaveBeenCalledWith( + expect.any(Object), + 1, + undefined, + undefined, + 7, + undefined + ); + }); + + it('should call getGuidanceSourcesForPlan with customQuestionId when provided', async () => { + const mockPlan = { id: 1, projectId: 100 }; + const mockProject = { id: 100 }; + + (Plan.findById as jest.Mock).mockResolvedValue(mockPlan); + (Project.findById as jest.Mock).mockResolvedValue(mockProject); + (hasPermissionOnProject as jest.Mock).mockResolvedValue(true); + (getGuidanceSourcesForPlan as jest.Mock).mockResolvedValue([]); + + await executeQuery( + query, + { planId: 1, versionedSectionId: 5, customQuestionId: 42 }, + researcherToken + ); + + expect(getGuidanceSourcesForPlan).toHaveBeenCalledWith( + expect.any(Object), + 1, + 5, + undefined, + undefined, + 42 + ); + }); + }); + + // ============================================================================ + // Mutation: addGuidance + // ============================================================================ + describe('Mutation.addGuidance', () => { + beforeEach(() => { + query = ` + mutation addGuidance($input: AddGuidanceInput!) { + addGuidance(input: $input) { + id + guidanceGroupId + guidanceText + tagId + errors { + general + } + } + } + `; + }); + + it('should create guidance when admin has permission', async () => { + const mockCreated = { id: 99, guidanceGroupId: 10, guidanceText: 'New guidance', tagId: 2 }; + + (hasPermissionOnGuidanceGroup as jest.Mock).mockResolvedValue(true); + (Guidance.prototype.create as jest.Mock).mockResolvedValue({ id: 99 }); + (Guidance.findById as jest.Mock).mockResolvedValue(mockCreated); + (markGuidanceGroupAsDirty as jest.Mock).mockResolvedValue(undefined); + + const vars = { input: { guidanceGroupId: 10, guidanceText: 'New guidance', tagId: 2 } }; + const result = await executeQuery(query, vars, adminToken); + + expect(result.body.singleResult.errors).toBeUndefined(); + expect(result.body.singleResult.data.addGuidance.id).toEqual(99); + expect(result.body.singleResult.data.addGuidance.guidanceText).toEqual('New guidance'); + expect(markGuidanceGroupAsDirty).toHaveBeenCalledWith(expect.any(Object), 10); + }); + + it('should return Forbidden when admin does not have permission', async () => { + (hasPermissionOnGuidanceGroup as jest.Mock).mockResolvedValue(false); + + const vars = { input: { guidanceGroupId: 10, guidanceText: 'New guidance', tagId: 2 } }; + const result = await executeQuery(query, vars, adminToken); + + expect(result.body.singleResult.errors).toBeDefined(); + expect(result.body.singleResult.errors[0].message).toEqual('Forbidden'); + }); + + it('should return error when guidance creation fails', async () => { + // Use a mockImplementation so the Guidance instance has a proper errors object + // and addError correctly mutates it (auto-mock doesn't run the real constructor). + const instanceErrors: Record = {}; + const mockInstance = { + guidanceGroupId: 10, + guidanceText: 'New guidance', + tagId: 2, + errors: instanceErrors, + addError: jest.fn().mockImplementation((field: string, msg: string) => { + instanceErrors[field] = msg; + }), + create: jest.fn().mockResolvedValue({ id: null, errors: {} }), + }; + + (hasPermissionOnGuidanceGroup as jest.Mock).mockResolvedValue(true); + (Guidance as unknown as jest.Mock).mockImplementationOnce(() => mockInstance); + + const vars = { input: { guidanceGroupId: 10, guidanceText: 'New guidance', tagId: 2 } }; + const result = await executeQuery(query, vars, adminToken); + + expect(result.body.singleResult.data.addGuidance.errors.general).toEqual('Unable to create the guidance'); + }); + }); + + // ============================================================================ + // Mutation: updateGuidance + // ============================================================================ + describe('Mutation.updateGuidance', () => { + beforeEach(() => { + query = ` + mutation updateGuidance($input: UpdateGuidanceInput!) { + updateGuidance(input: $input) { + id + guidanceGroupId + guidanceText + tagId + errors { + general + } + } + } + `; + }); + + it('should update guidance when admin has permission', async () => { + const mockGuidance = { + id: 5, + guidanceGroupId: 10, + guidanceText: 'Old guidance', + tagId: 1, + errors: {}, + hasErrors: () => false, + update: jest.fn().mockResolvedValue({ id: 5 }), + }; + const mockUpdated = { id: 5, guidanceGroupId: 10, guidanceText: 'Updated guidance', tagId: 2 }; + + (Guidance.findById as jest.Mock) + .mockResolvedValueOnce(mockGuidance) + .mockResolvedValueOnce(mockUpdated); + (hasPermissionOnGuidanceGroup as jest.Mock).mockResolvedValue(true); + (markGuidanceGroupAsDirty as jest.Mock).mockResolvedValue(undefined); + + const vars = { input: { guidanceId: 5, guidanceText: 'Updated guidance', tagId: 2 } }; + const result = await executeQuery(query, vars, adminToken); + + expect(result.body.singleResult.errors).toBeUndefined(); + expect(result.body.singleResult.data.updateGuidance.id).toEqual(5); + expect(result.body.singleResult.data.updateGuidance.guidanceText).toEqual('Updated guidance'); + expect(markGuidanceGroupAsDirty).toHaveBeenCalledWith(expect.any(Object), 10); + }); + + it('should return NotFound when guidance does not exist', async () => { + (Guidance.findById as jest.Mock).mockResolvedValue(null); + (hasPermissionOnGuidanceGroup as jest.Mock).mockResolvedValue(true); + + const vars = { input: { guidanceId: 999, guidanceText: 'Updated', tagId: 1 } }; + const result = await executeQuery(query, vars, adminToken); + + expect(result.body.singleResult.errors).toBeDefined(); + expect(result.body.singleResult.errors[0].message).toEqual('Guidance not found'); + }); + + it('should return Forbidden when admin does not have permission', async () => { + const mockGuidance = { id: 5, guidanceGroupId: 10 }; + + (Guidance.findById as jest.Mock).mockResolvedValue(mockGuidance); + (hasPermissionOnGuidanceGroup as jest.Mock).mockResolvedValue(false); + + const vars = { input: { guidanceId: 5, guidanceText: 'Updated', tagId: 1 } }; + const result = await executeQuery(query, vars, adminToken); + + expect(result.body.singleResult.errors).toBeDefined(); + expect(result.body.singleResult.errors[0].message).toEqual('Forbidden'); + }); + }); + + // ============================================================================ + // Mutation: removeGuidance + // ============================================================================ + describe('Mutation.removeGuidance', () => { + beforeEach(() => { + query = ` + mutation removeGuidance($guidanceId: Int!) { + removeGuidance(guidanceId: $guidanceId) { + id + guidanceGroupId + guidanceText + errors { + general + } + } + } + `; + }); + + it('should delete guidance when admin has permission', async () => { + const mockGuidance = { + id: 5, + guidanceGroupId: 10, + guidanceText: 'To be deleted', + errors: {}, + hasErrors: () => false, + delete: jest.fn(), + }; + const mockDeleted = { id: 5, guidanceGroupId: 10, guidanceText: 'To be deleted' }; + + (Guidance.findById as jest.Mock).mockResolvedValue(mockGuidance); + (hasPermissionOnGuidanceGroup as jest.Mock).mockResolvedValue(true); + mockGuidance.delete.mockResolvedValue(mockDeleted); + (markGuidanceGroupAsDirty as jest.Mock).mockResolvedValue(undefined); + + const result = await executeQuery(query, { guidanceId: 5 }, adminToken); + + expect(result.body.singleResult.errors).toBeUndefined(); + expect(result.body.singleResult.data.removeGuidance.id).toEqual(5); + expect(markGuidanceGroupAsDirty).toHaveBeenCalledWith(expect.any(Object), 10); + }); + + it('should return NotFound when guidance does not exist', async () => { + (Guidance.findById as jest.Mock).mockResolvedValue(null); + (hasPermissionOnGuidanceGroup as jest.Mock).mockResolvedValue(true); + + const result = await executeQuery(query, { guidanceId: 999 }, adminToken); + + expect(result.body.singleResult.errors).toBeDefined(); + expect(result.body.singleResult.errors[0].message).toEqual('Guidance not found'); + }); + + it('should return Forbidden when admin does not have permission', async () => { + const mockGuidance = { id: 5, guidanceGroupId: 10 }; + + (Guidance.findById as jest.Mock).mockResolvedValue(mockGuidance); + (hasPermissionOnGuidanceGroup as jest.Mock).mockResolvedValue(false); + + const result = await executeQuery(query, { guidanceId: 5 }, adminToken); + + expect(result.body.singleResult.errors).toBeDefined(); + expect(result.body.singleResult.errors[0].message).toEqual('Forbidden'); + }); + }); +}); diff --git a/src/resolvers/__tests__/questionCustomization.spec.ts b/src/resolvers/__tests__/questionCustomization.spec.ts index 4c50e3de..5092c8c3 100644 --- a/src/resolvers/__tests__/questionCustomization.spec.ts +++ b/src/resolvers/__tests__/questionCustomization.spec.ts @@ -15,7 +15,7 @@ import { resolvers } from '../../resolver'; import { logger } from "../../logger"; import { JWTAccessToken } from "../../services/tokenService"; import { QuestionCustomization } from '../../models/QuestionCustomization'; -import { CustomQuestion } from '../../models/CustomQuestion'; +import { CustomQuestion, PinnedQuestionTypeEnum } from '../../models/CustomQuestion'; import { VersionedQuestion } from '../../models/VersionedQuestion'; import { PinnedSectionTypeEnum } from '../../models/CustomSection'; import { User, UserRole } from "../../models/User"; @@ -810,13 +810,14 @@ describe('questionCustomization resolver', () => { `; }); - it('should move custom section successfully', async () => { + it('should move custom question successfully when no occupant exists', async () => { const input = { customQuestionId: 1, sectionType: 'BASE', sectionId: 2, pinnedQuestionType: 'BASE', - pinnedQuestionId: 10 + pinnedQuestionId: 10, + direction: 'DOWN' }; const mockCustomization = { id: 1, @@ -832,26 +833,30 @@ describe('questionCustomization resolver', () => { const mockMoved = { ...mockCustomization, pinnedQuestionType: PinnedSectionTypeEnum.BASE, - pinnedQuestionId: 10 + pinnedQuestionId: 10, + hasErrors: jest.fn().mockReturnValue(false) }; (CustomQuestion.findById as jest.Mock).mockResolvedValue(mockCustomization); (getValidatedCustomization as jest.Mock).mockResolvedValue(mockParent); + (CustomQuestion.findByPosition as jest.Mock).mockResolvedValue(null); mockCustomization.update.mockResolvedValue(mockMoved); const result = await executeQuery(query, { input }, adminToken); expect(result.body.singleResult.data.moveCustomQuestion.id).toEqual(1); + expect(CustomQuestion.findByPosition).toHaveBeenCalledTimes(1); expect(markTemplateCustomizationAsDirty).toHaveBeenCalled(); }); - it('should throw NotFoundError when custom section is not found', async () => { + it('should throw NotFoundError when custom question is not found', async () => { const input = { customQuestionId: 999, sectionType: 'BASE', sectionId: 2, pinnedQuestionType: 'BASE', - pinnedQuestionId: 10 + pinnedQuestionId: 10, + direction: 'DOWN' }; (CustomQuestion.findById as jest.Mock).mockResolvedValue(null); @@ -863,17 +868,20 @@ describe('questionCustomization resolver', () => { expect(result.body.singleResult.errors[0].message).toEqual('Not Found'); }); - it('should handle null QuestionType and QuestionId', async () => { + it('should handle null pinnedQuestionType and pinnedQuestionId', async () => { const input = { customQuestionId: 1, sectionType: 'BASE', sectionId: 2, pinnedQuestionType: null, - pinnedQuestionId: null + pinnedQuestionId: null, + direction: 'DOWN' }; const mockCustomization = { id: 1, templateCustomizationId: 10, + pinnedQuestionType: null, + pinnedQuestionId: null, hasErrors: jest.fn().mockReturnValue(false), update: jest.fn() } as undefined as CustomQuestion; @@ -882,12 +890,14 @@ describe('questionCustomization resolver', () => { ...mockCustomization, sectionType: PinnedSectionTypeEnum.BASE, sectionId: 2, - pinnedSectionType: null, - pinnedSectionId: null + pinnedQuestionType: null, + pinnedQuestionId: null, + hasErrors: jest.fn().mockReturnValue(false) } as undefined as CustomQuestion; (CustomQuestion.findById as jest.Mock).mockResolvedValue(mockCustomization); (getValidatedCustomization as jest.Mock).mockResolvedValue(mockParent); + (CustomQuestion.findByPosition as jest.Mock).mockResolvedValue(null); jest.spyOn(mockCustomization, 'update').mockResolvedValue(mockMoved); await executeQuery(query, { input }, adminToken); @@ -895,5 +905,202 @@ describe('questionCustomization resolver', () => { expect(mockCustomization.pinnedQuestionType).toBeNull(); expect(mockCustomization.pinnedQuestionId).toBeNull(); }); + + it('should swap positions with occupant when moving DOWN', async () => { + const input = { + customQuestionId: 1, + sectionType: 'BASE', + sectionId: 2, + pinnedQuestionType: 'BASE', + pinnedQuestionId: 5, + direction: 'DOWN' + }; + const mockCustomization = { + id: 1, + templateCustomizationId: 10, + sectionType: PinnedSectionTypeEnum.BASE, + sectionId: 2, + pinnedQuestionType: null, + pinnedQuestionId: null, + hasErrors: jest.fn().mockReturnValue(false), + update: jest.fn() + }; + const mockOccupant = { + id: 2, + templateCustomizationId: 10, + sectionType: PinnedSectionTypeEnum.BASE, + sectionId: 2, + pinnedQuestionType: PinnedQuestionTypeEnum.BASE, + pinnedQuestionId: 5, + hasErrors: jest.fn().mockReturnValue(false), + update: jest.fn() + }; + const mockParent = { id: 10, isDirty: false }; + const mockTempMoved = { + ...mockCustomization, + hasErrors: jest.fn().mockReturnValue(false) + }; + const mockMoved = { + ...mockCustomization, + pinnedQuestionType: PinnedSectionTypeEnum.BASE, + pinnedQuestionId: 5, + hasErrors: jest.fn().mockReturnValue(false) + }; + const mockSwapped = { + ...mockOccupant, + hasErrors: jest.fn().mockReturnValue(false) + }; + + (CustomQuestion.findById as jest.Mock).mockResolvedValue(mockCustomization); + (getValidatedCustomization as jest.Mock).mockResolvedValue(mockParent); + (CustomQuestion.findByPosition as jest.Mock).mockResolvedValue(mockOccupant); + mockCustomization.update + .mockResolvedValueOnce(mockTempMoved) + .mockResolvedValueOnce(mockMoved); + mockOccupant.update.mockResolvedValue(mockSwapped); + + const result = await executeQuery(query, { input }, adminToken); + + expect(result.body.singleResult.data.moveCustomQuestion.id).toEqual(1); + expect(mockCustomization.update).toHaveBeenCalledTimes(2); + expect(mockOccupant.update).toHaveBeenCalledTimes(1); + expect(mockOccupant.pinnedQuestionType).toEqual(PinnedQuestionTypeEnum.CUSTOM); + expect(mockOccupant.pinnedQuestionId).toEqual(1); + expect(markTemplateCustomizationAsDirty).toHaveBeenCalled(); + }); + + it('should swap positions with occupant when moving UP', async () => { + const originalSectionType = PinnedSectionTypeEnum.BASE; + const originalSectionId = 2; + const originalPinnedQuestionType = PinnedQuestionTypeEnum.BASE; + const originalPinnedQuestionId = 5; + const input = { + customQuestionId: 1, + sectionType: 'BASE', + sectionId: 2, + pinnedQuestionType: null, + pinnedQuestionId: null, + direction: 'UP' + }; + const mockCustomization = { + id: 1, + templateCustomizationId: 10, + sectionType: originalSectionType, + sectionId: originalSectionId, + pinnedQuestionType: originalPinnedQuestionType, + pinnedQuestionId: originalPinnedQuestionId, + hasErrors: jest.fn().mockReturnValue(false), + update: jest.fn() + }; + const mockOccupant = { + id: 2, + sectionType: null, + sectionId: null, + pinnedQuestionType: null, + pinnedQuestionId: null, + hasErrors: jest.fn().mockReturnValue(false), + update: jest.fn() + }; + const mockParent = { id: 10, isDirty: false }; + const mockTempMoved = { + ...mockCustomization, + hasErrors: jest.fn().mockReturnValue(false) + }; + const mockMoved = { + ...mockCustomization, + pinnedQuestionType: null, + pinnedQuestionId: null, + hasErrors: jest.fn().mockReturnValue(false) + }; + const mockSwapped = { + ...mockOccupant, + hasErrors: jest.fn().mockReturnValue(false) + }; + + (CustomQuestion.findById as jest.Mock).mockResolvedValue(mockCustomization); + (getValidatedCustomization as jest.Mock).mockResolvedValue(mockParent); + (CustomQuestion.findByPosition as jest.Mock).mockResolvedValue(mockOccupant); + mockCustomization.update + .mockResolvedValueOnce(mockTempMoved) + .mockResolvedValueOnce(mockMoved); + mockOccupant.update.mockResolvedValue(mockSwapped); + + const result = await executeQuery(query, { input }, adminToken); + + expect(result.body.singleResult.data.moveCustomQuestion.id).toEqual(1); + expect(mockOccupant.sectionType).toEqual(originalSectionType); + expect(mockOccupant.sectionId).toEqual(originalSectionId); + expect(mockOccupant.pinnedQuestionType).toEqual(originalPinnedQuestionType); + expect(mockOccupant.pinnedQuestionId).toEqual(originalPinnedQuestionId); + expect(markTemplateCustomizationAsDirty).toHaveBeenCalled(); + }); + + it('should re-anchor tail question when moving UP with no occupant', async () => { + const originalSectionType = PinnedSectionTypeEnum.BASE; + const originalSectionId = 2; + const originalPinnedQuestionType = PinnedQuestionTypeEnum.CUSTOM; + const originalPinnedQuestionId = 3; + const input = { + customQuestionId: 1, + sectionType: 'BASE', + sectionId: 2, + pinnedQuestionType: null, + pinnedQuestionId: null, + direction: 'UP' + }; + const mockCustomization = { + id: 1, + templateCustomizationId: 10, + sectionType: originalSectionType, + sectionId: originalSectionId, + pinnedQuestionType: originalPinnedQuestionType, + pinnedQuestionId: originalPinnedQuestionId, + hasErrors: jest.fn().mockReturnValue(false), + update: jest.fn() + }; + const mockTailQuestion = { + id: 3, + sectionType: null, + sectionId: null, + pinnedQuestionType: null, + pinnedQuestionId: null, + hasErrors: jest.fn().mockReturnValue(false), + update: jest.fn() + }; + const mockParent = { id: 10, isDirty: false }; + const mockTempMoved = { + ...mockCustomization, + hasErrors: jest.fn().mockReturnValue(false) + }; + const mockMoved = { + ...mockCustomization, + hasErrors: jest.fn().mockReturnValue(false) + }; + const mockReanchored = { + ...mockTailQuestion, + hasErrors: jest.fn().mockReturnValue(false) + }; + + (CustomQuestion.findById as jest.Mock).mockResolvedValue(mockCustomization); + (getValidatedCustomization as jest.Mock).mockResolvedValue(mockParent); + (CustomQuestion.findByPosition as jest.Mock) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(mockTailQuestion); + mockCustomization.update + .mockResolvedValueOnce(mockTempMoved) + .mockResolvedValueOnce(mockMoved); + mockTailQuestion.update.mockResolvedValue(mockReanchored); + + const result = await executeQuery(query, { input }, adminToken); + + expect(result.body.singleResult.data.moveCustomQuestion.id).toEqual(1); + expect(CustomQuestion.findByPosition).toHaveBeenCalledTimes(2); + expect(mockTailQuestion.update).toHaveBeenCalledTimes(1); + expect(mockTailQuestion.sectionType).toEqual(originalSectionType); + expect(mockTailQuestion.sectionId).toEqual(originalSectionId); + expect(mockTailQuestion.pinnedQuestionType).toEqual(originalPinnedQuestionType); + expect(mockTailQuestion.pinnedQuestionId).toEqual(originalPinnedQuestionId); + expect(markTemplateCustomizationAsDirty).toHaveBeenCalled(); + }); }); }); diff --git a/src/resolvers/__tests__/versionedQuestion.spec.ts b/src/resolvers/__tests__/versionedQuestion.spec.ts new file mode 100644 index 00000000..e2223955 --- /dev/null +++ b/src/resolvers/__tests__/versionedQuestion.spec.ts @@ -0,0 +1,704 @@ +import casual from "casual"; + +// Mock the authenticatedResolver HOF before importing resolvers +jest.mock('../../services/authService', () => ({ + ...jest.requireActual('../../services/authService'), + authenticatedResolver: jest.fn((ref, level, resolver) => resolver), +})); + +import { ApolloServer } from "@apollo/server"; +import { typeDefs } from "../../schema"; +import { resolvers } from '../../resolver'; + +import { logger } from "../../logger"; +import { JWTAccessToken } from "../../services/tokenService"; +import { VersionedQuestion } from '../../models/VersionedQuestion'; +import { VersionedCustomQuestion } from '../../models/VersionedCustomQuestion'; +import { Answer } from '../../models/Answer'; +import { VersionedQuestionCondition } from '../../models/VersionedQuestionCondition'; +import { VersionedTemplate } from '../../models/VersionedTemplate'; +import { VersionedTemplateCustomization } from '../../models/VersionedTemplateCustomization'; +import { VersionedQuestionCustomization } from '../../models/VersionedQuestionCustomization'; +import { Affiliation } from '../../models/Affiliation'; +import { UserRole } from "../../models/User"; +import { buildContext, mockToken } from "../../__mocks__/context"; + +jest.mock('../../context.ts'); +jest.mock('../../datasources/cache'); + +jest.mock('../../models/VersionedQuestion'); +jest.mock('../../models/VersionedCustomQuestion'); +jest.mock('../../models/Answer'); +jest.mock('../../models/VersionedQuestionCondition'); +jest.mock('../../models/VersionedQuestionCustomization'); + +let testServer: ApolloServer; +let affiliationId: string; +let researcherToken: JWTAccessToken; +let query: string; + +async function executeQuery( + query: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + variables: any, + token: JWTAccessToken + // eslint-disable-next-line @typescript-eslint/no-explicit-any +): Promise { + const context = buildContext(logger, token, null); + return await testServer.executeOperation( + { query, variables }, + { contextValue: context }, + ); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +async function executeQueryAnon(query: string, variables: any): Promise { + const context = buildContext(logger, null, null); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return await testServer.executeOperation({ query, variables }, { contextValue: context }) as any; +} + +beforeEach(async () => { + jest.resetAllMocks(); + + testServer = new ApolloServer({ typeDefs, resolvers }); + + affiliationId = casual.url; + + researcherToken = await mockToken(); + researcherToken.affiliationId = affiliationId; + researcherToken.role = UserRole.RESEARCHER; +}); + +afterEach(() => { + jest.clearAllMocks(); +}); + +describe('versionedQuestion resolvers', () => { + + // ============================================================================ + // Query: publishedQuestion + // ============================================================================ + describe('Query.publishedQuestion', () => { + beforeEach(() => { + query = ` + query publishedQuestion($versionedQuestionId: Int!) { + publishedQuestion(versionedQuestionId: $versionedQuestionId) { + id + questionText + requirementText + guidanceText + sampleText + required + versionedTemplateId + versionedSectionId + versionedQuestionConditions { + id + } + } + } + `; + }); + + it('should return the question when found', async () => { + const mockQuestion = { + id: 1, + questionText: 'What is your data management plan?', + requirementText: 'Required by funder', + guidanceText: 'Some guidance', + sampleText: 'Sample answer', + required: true, + versionedTemplateId: 10, + versionedSectionId: 5, + }; + + (VersionedQuestion.findById as jest.Mock).mockResolvedValue(mockQuestion); + (VersionedQuestionCondition.findByVersionedQuestionId as jest.Mock).mockResolvedValue([]); + (VersionedQuestionCustomization.findActiveByTemplateAffiliationAndQuestion as jest.Mock).mockResolvedValue(null); + + const result = await executeQuery(query, { versionedQuestionId: 1 }, researcherToken); + + expect(result.body.singleResult.errors).toBeUndefined(); + expect(result.body.singleResult.data.publishedQuestion.id).toEqual(1); + expect(result.body.singleResult.data.publishedQuestion.questionText).toEqual('What is your data management plan?'); + expect(VersionedQuestion.findById).toHaveBeenCalledWith( + 'publishedQuestion resolver', + expect.any(Object), + 1 + ); + expect(VersionedQuestionCustomization.findActiveByTemplateAffiliationAndQuestion).toHaveBeenCalledWith( + 'publishedQuestion resolver', + expect.any(Object), + affiliationId, + 1 + ); + }); + + it('should return customization fields when a customization exists', async () => { + const mockQuestion = { + id: 1, + questionText: 'What is your data management plan?', + requirementText: 'Required by funder', + guidanceText: 'Original guidance', + sampleText: 'Original sample', + required: true, + versionedTemplateId: 10, + versionedSectionId: 5, + }; + const mockCustomization = { + id: 99, + guidanceText: 'Org custom guidance', + sampleText: 'Org custom sample', + }; + + (VersionedQuestion.findById as jest.Mock).mockResolvedValue(mockQuestion); + (VersionedQuestionCondition.findByVersionedQuestionId as jest.Mock).mockResolvedValue([]); + (VersionedQuestionCustomization.findActiveByTemplateAffiliationAndQuestion as jest.Mock).mockResolvedValue(mockCustomization); + + const custQuery = ` + query publishedQuestion($versionedQuestionId: Int!) { + publishedQuestion(versionedQuestionId: $versionedQuestionId) { + id + guidanceText + sampleText + customizationId + customizationGuidanceText + customizationSampleText + } + } + `; + + const result = await executeQuery(custQuery, { versionedQuestionId: 1 }, researcherToken); + + expect(result.body.singleResult.errors).toBeUndefined(); + const q = result.body.singleResult.data.publishedQuestion; + expect(q.id).toEqual(1); + expect(q.guidanceText).toEqual('Original guidance'); + expect(q.sampleText).toEqual('Original sample'); + expect(q.customizationId).toEqual(99); + expect(q.customizationGuidanceText).toEqual('Org custom guidance'); + expect(q.customizationSampleText).toEqual('Org custom sample'); + }); + + it('should return null customization fields when no customization exists', async () => { + const mockQuestion = { + id: 1, + questionText: 'Test question', + required: false, + versionedTemplateId: 10, + versionedSectionId: 5, + }; + + (VersionedQuestion.findById as jest.Mock).mockResolvedValue(mockQuestion); + (VersionedQuestionCondition.findByVersionedQuestionId as jest.Mock).mockResolvedValue([]); + (VersionedQuestionCustomization.findActiveByTemplateAffiliationAndQuestion as jest.Mock).mockResolvedValue(null); + + const custQuery = ` + query publishedQuestion($versionedQuestionId: Int!) { + publishedQuestion(versionedQuestionId: $versionedQuestionId) { + id + customizationId + customizationGuidanceText + customizationSampleText + } + } + `; + + const result = await executeQuery(custQuery, { versionedQuestionId: 1 }, researcherToken); + + expect(result.body.singleResult.errors).toBeUndefined(); + const q = result.body.singleResult.data.publishedQuestion; + expect(q.customizationId).toBeNull(); + expect(q.customizationGuidanceText).toBeNull(); + expect(q.customizationSampleText).toBeNull(); + }); + + it('should return null when question is not found', async () => { + (VersionedQuestion.findById as jest.Mock).mockResolvedValue(null); + (VersionedQuestionCustomization.findActiveByTemplateAffiliationAndQuestion as jest.Mock).mockResolvedValue(null); + + const result = await executeQuery(query, { versionedQuestionId: 999 }, researcherToken); + + expect(result.body.singleResult.errors).toBeUndefined(); + expect(result.body.singleResult.data.publishedQuestion).toBeNull(); + }); + + it('should return Authentication error when no token is provided', async () => { + const result = await executeQueryAnon(query, { versionedQuestionId: 1 }); + + expect(result.body.singleResult.errors).toBeDefined(); + expect(result.body.singleResult.errors[0].message).toEqual('Unauthorized'); + }); + + it('should resolve versionedQuestionConditions via chained resolver', async () => { + const mockQuestion = { + id: 1, + questionText: 'Test question', + required: false, + versionedTemplateId: 10, + versionedSectionId: 5, + }; + const mockConditions = [ + { id: 11 }, + { id: 12 }, + ]; + + (VersionedQuestion.findById as jest.Mock).mockResolvedValue(mockQuestion); + (VersionedQuestionCondition.findByVersionedQuestionId as jest.Mock).mockResolvedValue(mockConditions); + (VersionedQuestionCustomization.findActiveByTemplateAffiliationAndQuestion as jest.Mock).mockResolvedValue(null); + + const result = await executeQuery(query, { versionedQuestionId: 1 }, researcherToken); + + expect(result.body.singleResult.errors).toBeUndefined(); + expect(result.body.singleResult.data.publishedQuestion.versionedQuestionConditions).toHaveLength(2); + expect(result.body.singleResult.data.publishedQuestion.versionedQuestionConditions[0].id).toEqual(11); + expect(VersionedQuestionCondition.findByVersionedQuestionId).toHaveBeenCalledWith( + 'Chained VersionedQuestion.versionedQuestionConditions', + expect.any(Object), + 1 + ); + }); + + it('should resolve ownerAffiliation via chained resolver', async () => { + const mockQuestion = { + id: 1, + questionText: 'Test question', + required: false, + versionedTemplateId: 10, + versionedSectionId: 5, + }; + const mockTemplate = { id: 10, ownerId: 'https://ror.org/abc' }; + const mockAffiliation = { uri: 'https://ror.org/abc', name: 'Test University', displayName: 'Test University' }; + + (VersionedQuestion.findById as jest.Mock).mockResolvedValue(mockQuestion); + (VersionedQuestionCondition.findByVersionedQuestionId as jest.Mock).mockResolvedValue([]); + (VersionedQuestionCustomization.findActiveByTemplateAffiliationAndQuestion as jest.Mock).mockResolvedValue(null); + jest.spyOn(VersionedTemplate, 'findById').mockResolvedValue(mockTemplate as unknown as VersionedTemplate); + jest.spyOn(Affiliation, 'findByURI').mockResolvedValue(mockAffiliation as unknown as Affiliation); + + const ownerQuery = ` + query publishedQuestion($versionedQuestionId: Int!) { + publishedQuestion(versionedQuestionId: $versionedQuestionId) { + id + ownerAffiliation { + uri + name + } + } + } + `; + + const result = await executeQuery(ownerQuery, { versionedQuestionId: 1 }, researcherToken); + + expect(result.body.singleResult.errors).toBeUndefined(); + expect(result.body.singleResult.data.publishedQuestion.ownerAffiliation.name).toEqual('Test University'); + expect(VersionedTemplate.findById).toHaveBeenCalledWith( + 'VersionedQuestion.ownerAffiliation resolver', + expect.any(Object), + 10 + ); + }); + + it('should resolve customizationOwnerAffiliation via chained resolver', async () => { + const mockQuestion = { + id: 1, + questionText: 'Test question', + required: false, + versionedTemplateId: 10, + versionedSectionId: 5, + }; + const mockCustomization = { id: 99, guidanceText: 'Custom guidance', sampleText: null }; + const mockAffiliation = { uri: affiliationId, name: 'Org University', displayName: 'Org University' }; + + (VersionedQuestion.findById as jest.Mock).mockResolvedValue(mockQuestion); + (VersionedQuestionCondition.findByVersionedQuestionId as jest.Mock).mockResolvedValue([]); + (VersionedQuestionCustomization.findActiveByTemplateAffiliationAndQuestion as jest.Mock).mockResolvedValue(mockCustomization); + jest.spyOn(Affiliation, 'findByURI').mockResolvedValue(mockAffiliation as unknown as Affiliation); + + const custOwnerQuery = ` + query publishedQuestion($versionedQuestionId: Int!) { + publishedQuestion(versionedQuestionId: $versionedQuestionId) { + id + customizationOwnerAffiliation { + uri + name + } + } + } + `; + + const result = await executeQuery(custOwnerQuery, { versionedQuestionId: 1 }, researcherToken); + + expect(result.body.singleResult.errors).toBeUndefined(); + expect(result.body.singleResult.data.publishedQuestion.customizationOwnerAffiliation.name).toEqual('Org University'); + expect(Affiliation.findByURI).toHaveBeenCalledWith( + 'VersionedQuestion.customizationOwnerAffiliation resolver', + expect.any(Object), + affiliationId + ); + }); + + it('should return null customizationOwnerAffiliation when no customization exists', async () => { + const mockQuestion = { + id: 1, + questionText: 'Test question', + required: false, + versionedTemplateId: 10, + versionedSectionId: 5, + }; + + (VersionedQuestion.findById as jest.Mock).mockResolvedValue(mockQuestion); + (VersionedQuestionCondition.findByVersionedQuestionId as jest.Mock).mockResolvedValue([]); + (VersionedQuestionCustomization.findActiveByTemplateAffiliationAndQuestion as jest.Mock).mockResolvedValue(null); + + const custOwnerQuery = ` + query publishedQuestion($versionedQuestionId: Int!) { + publishedQuestion(versionedQuestionId: $versionedQuestionId) { + id + customizationOwnerAffiliation { uri } + } + } + `; + + const result = await executeQuery(custOwnerQuery, { versionedQuestionId: 1 }, researcherToken); + + expect(result.body.singleResult.errors).toBeUndefined(); + expect(result.body.singleResult.data.publishedQuestion.customizationOwnerAffiliation).toBeNull(); + }); + }); + + // ============================================================================ + // Query: publishedCustomQuestion + // ============================================================================ + describe('Query.publishedCustomQuestion', () => { + beforeEach(() => { + query = ` + query publishedCustomQuestion($versionedCustomQuestionId: Int!) { + publishedCustomQuestion(versionedCustomQuestionId: $versionedCustomQuestionId) { + id + questionText + requirementText + guidanceText + sampleText + required + json + versionedTemplateCustomizationId + } + } + `; + }); + + it('should return the custom question when found', async () => { + const mockCustomQuestion = { + id: 5, + questionText: 'Custom question text', + requirementText: 'Custom requirement', + guidanceText: 'Custom guidance', + sampleText: 'Custom sample', + required: false, + json: '{"type":"textArea"}', + versionedTemplateCustomizationId: 20, + }; + + (VersionedCustomQuestion.findById as jest.Mock).mockResolvedValue(mockCustomQuestion); + + const result = await executeQuery(query, { versionedCustomQuestionId: 5 }, researcherToken); + + expect(result.body.singleResult.errors).toBeUndefined(); + expect(result.body.singleResult.data.publishedCustomQuestion.id).toEqual(5); + expect(result.body.singleResult.data.publishedCustomQuestion.questionText).toEqual('Custom question text'); + expect(result.body.singleResult.data.publishedCustomQuestion.json).toEqual('{"type":"textArea"}'); + expect(VersionedCustomQuestion.findById).toHaveBeenCalledWith( + 'publishedCustomQuestion resolver', + expect.any(Object), + 5 + ); + }); + + it('should return null when custom question is not found', async () => { + (VersionedCustomQuestion.findById as jest.Mock).mockResolvedValue(null); + + const result = await executeQuery(query, { versionedCustomQuestionId: 999 }, researcherToken); + + expect(result.body.singleResult.errors).toBeUndefined(); + expect(result.body.singleResult.data.publishedCustomQuestion).toBeNull(); + }); + + it('should return Authentication error when no token is provided', async () => { + const result = await executeQueryAnon(query, { versionedCustomQuestionId: 5 }); + + expect(result.body.singleResult.errors).toBeDefined(); + expect(result.body.singleResult.errors[0].message).toEqual('Unauthorized'); + }); + + it('should resolve ownerAffiliation via chained resolver', async () => { + const mockCustomQuestion = { + id: 5, + questionText: 'Custom question', + required: false, + json: '{"type":"textArea"}', + versionedTemplateCustomizationId: 20, + }; + const mockVtc = { id: 20, currentVersionedTemplateId: 10 }; + const mockTemplate = { id: 10, ownerId: 'https://ror.org/xyz' }; + const mockAffiliation = { uri: 'https://ror.org/xyz', name: 'Owner Org', displayName: 'Owner Org' }; + + (VersionedCustomQuestion.findById as jest.Mock).mockResolvedValue(mockCustomQuestion); + jest.spyOn(VersionedTemplateCustomization, 'findById').mockResolvedValue(mockVtc as unknown as VersionedTemplateCustomization); + jest.spyOn(VersionedTemplate, 'findById').mockResolvedValue(mockTemplate as unknown as VersionedTemplate); + jest.spyOn(Affiliation, 'findByURI').mockResolvedValue(mockAffiliation as unknown as Affiliation); + + const ownerQuery = ` + query publishedCustomQuestion($versionedCustomQuestionId: Int!) { + publishedCustomQuestion(versionedCustomQuestionId: $versionedCustomQuestionId) { + id + ownerAffiliation { + uri + name + } + } + } + `; + + const result = await executeQuery(ownerQuery, { versionedCustomQuestionId: 5 }, researcherToken); + + expect(result.body.singleResult.errors).toBeUndefined(); + expect(result.body.singleResult.data.publishedCustomQuestion.ownerAffiliation.name).toEqual('Owner Org'); + expect(VersionedTemplateCustomization.findById).toHaveBeenCalledWith( + 'VersionedCustomQuestion.ownerAffiliation resolver', + expect.any(Object), + 20 + ); + expect(VersionedTemplate.findById).toHaveBeenCalledWith( + 'VersionedCustomQuestion.ownerAffiliation resolver', + expect.any(Object), + 10 + ); + }); + + it('should return null ownerAffiliation when versioned template customization is not found', async () => { + const mockCustomQuestion = { + id: 5, + questionText: 'Custom question', + required: false, + json: '{"type":"textArea"}', + versionedTemplateCustomizationId: 20, + }; + + (VersionedCustomQuestion.findById as jest.Mock).mockResolvedValue(mockCustomQuestion); + jest.spyOn(VersionedTemplateCustomization, 'findById').mockResolvedValue(null); + + const ownerQuery = ` + query publishedCustomQuestion($versionedCustomQuestionId: Int!) { + publishedCustomQuestion(versionedCustomQuestionId: $versionedCustomQuestionId) { + id + ownerAffiliation { uri } + } + } + `; + + const result = await executeQuery(ownerQuery, { versionedCustomQuestionId: 5 }, researcherToken); + + expect(result.body.singleResult.errors).toBeUndefined(); + expect(result.body.singleResult.data.publishedCustomQuestion.ownerAffiliation).toBeNull(); + }); + }); + + // ============================================================================ + // Query: publishedQuestions + // ============================================================================ + describe('Query.publishedQuestions', () => { + beforeEach(() => { + query = ` + query publishedQuestions($planId: Int!, $versionedSectionId: Int!) { + publishedQuestions(planId: $planId, versionedSectionId: $versionedSectionId) { + id + questionText + hasAnswer + questionType + versionedQuestionId + customQuestionId + } + } + `; + }); + + it('should return ordered base and custom questions with answer flags', async () => { + const mockBaseQuestions = [ + { id: 1, questionText: 'Base Q1', required: true }, + { id: 2, questionText: 'Base Q2', required: false }, + ]; + const mockCustomQuestions = [ + { + id: 10, + questionText: 'Custom Q1', + required: false, + pinnedVersionedQuestionId: 1, + pinnedVersionedQuestionType: 'BASE', + }, + ]; + const mockBaseAnswers = [{ versionedQuestionId: 1 }]; + const mockCustomAnswers = []; + + (VersionedQuestion.findByVersionedSectionId as jest.Mock).mockResolvedValue(mockBaseQuestions); + (VersionedCustomQuestion.findByVersionedSectionIdAndType as jest.Mock).mockResolvedValue(mockCustomQuestions); + (Answer.findFilledAnswersByQuestionIds as jest.Mock).mockResolvedValue(mockBaseAnswers); + (Answer.findFilledAnswersByCustomQuestionIds as jest.Mock).mockResolvedValue(mockCustomAnswers); + + const result = await executeQuery(query, { planId: 1, versionedSectionId: 5 }, researcherToken); + + expect(result.body.singleResult.errors).toBeUndefined(); + const questions = result.body.singleResult.data.publishedQuestions; + + // Base Q1 + Custom Q1 (pinned after Q1) + Base Q2 + expect(questions).toHaveLength(3); + expect(questions[0].id).toEqual(1); + expect(questions[0].questionType).toEqual('BASE'); + expect(questions[0].hasAnswer).toBe(true); + expect(questions[0].versionedQuestionId).toEqual(1); + + expect(questions[1].id).toEqual(10); + expect(questions[1].questionType).toEqual('CUSTOM'); + expect(questions[1].hasAnswer).toBe(false); + expect(questions[1].customQuestionId).toEqual(10); + + expect(questions[2].id).toEqual(2); + expect(questions[2].questionType).toEqual('BASE'); + expect(questions[2].hasAnswer).toBe(false); + }); + + it('should place custom question first when pinnedVersionedQuestionId is null', async () => { + const mockBaseQuestions = [{ id: 1, questionText: 'Base Q1', required: false }]; + const mockCustomQuestions = [ + { + id: 10, + questionText: 'Unpinned Custom', + required: false, + pinnedVersionedQuestionId: null, + pinnedVersionedQuestionType: null, + }, + ]; + + (VersionedQuestion.findByVersionedSectionId as jest.Mock).mockResolvedValue(mockBaseQuestions); + (VersionedCustomQuestion.findByVersionedSectionIdAndType as jest.Mock).mockResolvedValue(mockCustomQuestions); + (Answer.findFilledAnswersByQuestionIds as jest.Mock).mockResolvedValue([]); + (Answer.findFilledAnswersByCustomQuestionIds as jest.Mock).mockResolvedValue([]); + + const result = await executeQuery(query, { planId: 1, versionedSectionId: 5 }, researcherToken); + + expect(result.body.singleResult.errors).toBeUndefined(); + const questions = result.body.singleResult.data.publishedQuestions; + expect(questions[0].id).toEqual(10); + expect(questions[0].questionType).toEqual('CUSTOM'); + expect(questions[1].id).toEqual(1); + }); + + it('should return only base questions when no custom questions exist', async () => { + const mockBaseQuestions = [ + { id: 1, questionText: 'Base Q1', required: true }, + { id: 2, questionText: 'Base Q2', required: false }, + ]; + + (VersionedQuestion.findByVersionedSectionId as jest.Mock).mockResolvedValue(mockBaseQuestions); + (VersionedCustomQuestion.findByVersionedSectionIdAndType as jest.Mock).mockResolvedValue([]); + (Answer.findFilledAnswersByQuestionIds as jest.Mock).mockResolvedValue([]); + (Answer.findFilledAnswersByCustomQuestionIds as jest.Mock).mockResolvedValue([]); + + const result = await executeQuery(query, { planId: 1, versionedSectionId: 5 }, researcherToken); + + expect(result.body.singleResult.errors).toBeUndefined(); + expect(result.body.singleResult.data.publishedQuestions).toHaveLength(2); + expect(result.body.singleResult.data.publishedQuestions.every(q => q.questionType === 'BASE')).toBe(true); + }); + + it('should return empty array when no questions exist', async () => { + (VersionedQuestion.findByVersionedSectionId as jest.Mock).mockResolvedValue([]); + (VersionedCustomQuestion.findByVersionedSectionIdAndType as jest.Mock).mockResolvedValue([]); + (Answer.findFilledAnswersByQuestionIds as jest.Mock).mockResolvedValue([]); + (Answer.findFilledAnswersByCustomQuestionIds as jest.Mock).mockResolvedValue([]); + + const result = await executeQuery(query, { planId: 1, versionedSectionId: 5 }, researcherToken); + + expect(result.body.singleResult.errors).toBeUndefined(); + expect(result.body.singleResult.data.publishedQuestions).toHaveLength(0); + }); + + it('should return Authentication error when no token', async () => { + const result = await executeQueryAnon(query, { planId: 1, versionedSectionId: 5 }); + + expect(result.body.singleResult.errors).toBeDefined(); + expect(result.body.singleResult.errors[0].message).toEqual('Unauthorized'); + }); + }); + + // ============================================================================ + // Query: publishedCustomQuestions + // ============================================================================ + describe('Query.publishedCustomQuestions', () => { + beforeEach(() => { + query = ` + query publishedCustomQuestions($planId: Int!, $versionedCustomSectionId: Int!) { + publishedCustomQuestions(planId: $planId, versionedCustomSectionId: $versionedCustomSectionId) { + id + questionText + hasAnswer + questionType + customQuestionId + json + } + } + `; + }); + + it('should return custom questions with answer flags', async () => { + const mockQuestions = [ + { id: 10, questionText: 'Custom Q1', required: true, json: '{"type":"textArea"}' }, + { id: 11, questionText: 'Custom Q2', required: false, json: '{"type":"checkbox"}' }, + ]; + const mockAnswers = [{ versionedCustomQuestionId: 10 }]; + + (VersionedCustomQuestion.findByVersionedCustomSectionId as jest.Mock).mockResolvedValue(mockQuestions); + (Answer.findFilledAnswersByCustomQuestionIds as jest.Mock).mockResolvedValue(mockAnswers); + + const result = await executeQuery( + query, + { planId: 1, versionedCustomSectionId: 7 }, + researcherToken + ); + + expect(result.body.singleResult.errors).toBeUndefined(); + const questions = result.body.singleResult.data.publishedCustomQuestions; + expect(questions).toHaveLength(2); + + expect(questions[0].id).toEqual(10); + expect(questions[0].questionType).toEqual('CUSTOM'); + expect(questions[0].hasAnswer).toBe(true); + expect(questions[0].customQuestionId).toEqual(10); + expect(questions[0].json).toEqual('{"type":"textArea"}'); + + expect(questions[1].id).toEqual(11); + expect(questions[1].hasAnswer).toBe(false); + }); + + it('should return empty array when no custom questions exist', async () => { + (VersionedCustomQuestion.findByVersionedCustomSectionId as jest.Mock).mockResolvedValue([]); + (Answer.findFilledAnswersByCustomQuestionIds as jest.Mock).mockResolvedValue([]); + + const result = await executeQuery( + query, + { planId: 1, versionedCustomSectionId: 7 }, + researcherToken + ); + + expect(result.body.singleResult.errors).toBeUndefined(); + expect(result.body.singleResult.data.publishedCustomQuestions).toHaveLength(0); + }); + + it('should return Authentication error when no token', async () => { + const result = await executeQueryAnon(query, { planId: 1, versionedCustomSectionId: 7 }); + + expect(result.body.singleResult.errors).toBeDefined(); + expect(result.body.singleResult.errors[0].message).toEqual('Unauthorized'); + }); + }); +}); diff --git a/src/resolvers/answer.ts b/src/resolvers/answer.ts index bbbba011..cde8e099 100644 --- a/src/resolvers/answer.ts +++ b/src/resolvers/answer.ts @@ -12,6 +12,8 @@ import { isAuthorized } from "../services/authService"; import { sendProjectCollaboratorsCommentsAddedEmail } from '../services/emailService'; import { canDeleteComment } from "../services/commentPermissions"; import { Resolvers } from "../types"; +import { VersionedCustomQuestion } from "../models/VersionedCustomQuestion"; +import { VersionedCustomSection } from "../models/VersionedCustomSection"; import { Answer } from "../models/Answer"; import { VersionedQuestion } from "../models/VersionedQuestion"; import { VersionedSection } from "../models/VersionedSection"; @@ -51,8 +53,8 @@ export const resolvers: Resolvers = { }, // return the answer for the given versionedQuestionId - answerByVersionedQuestionId: async (_, { planId, versionedQuestionId }, context: MyContext): Promise => { - const reference = 'planSectionAnswers resolver'; + answerByVersionedQuestionId: async (_, { planId, versionedQuestionId, versionedCustomQuestionId }, context: MyContext): Promise => { + const reference = 'answerByVersionedQuestionId resolver'; try { if (isAuthorized(context.token)) { const plan = await Plan.findById(reference, context, planId); @@ -61,8 +63,14 @@ export const resolvers: Resolvers = { } const project = await Project.findById(reference, context, plan.projectId); if (await hasPermissionOnProject(context, project, ProjectCollaboratorAccessLevel.COMMENT)) { - const temp = await Answer.findByPlanIdAndVersionedQuestionId(reference, context, planId, versionedQuestionId); - return temp; + if (versionedCustomQuestionId) { + return await Answer.findByPlanIdAndVersionedCustomQuestionId( + reference, context, planId, versionedCustomQuestionId + ); + } + return await Answer.findByPlanIdAndVersionedQuestionId( + reference, context, planId, versionedQuestionId + ); } } throw context?.token ? ForbiddenError() : AuthenticationError(); @@ -101,7 +109,7 @@ export const resolvers: Resolvers = { Mutation: { // Create a new answer - addAnswer: async (_, { planId, versionedSectionId, versionedQuestionId, json }, context: MyContext): Promise => { + addAnswer: async (_, { planId, versionedSectionId, versionedQuestionId, versionedCustomSectionId, versionedCustomQuestionId, json }, context: MyContext): Promise => { const reference = 'addAnswer resolver'; try { if (isAuthorized(context.token)) { @@ -112,7 +120,7 @@ export const resolvers: Resolvers = { const project = await Project.findById(reference, context, plan.projectId); if (await hasPermissionOnProject(context, project)) { - const answer = new Answer({ planId, versionedSectionId, versionedQuestionId, json }); + const answer = new Answer({ planId, versionedSectionId, versionedQuestionId, versionedCustomSectionId, versionedCustomQuestionId, json }); const newAnswer = await answer.create(context); if (newAnswer && !newAnswer.hasErrors()) { // Update the maDMP version of the Plan @@ -317,6 +325,20 @@ export const resolvers: Resolvers = { } return null; }, + // The section the answer's question belongs to + versionedCustomSection: async (parent: Answer, _, context: MyContext): Promise => { + if (parent?.versionedCustomSectionId) { + return await VersionedCustomSection.findById('Answer versionedCustomSection resolver', context, parent.versionedCustomSectionId); + } + return null; + }, + // The question the answer is associated with + versionedCustomQuestion: async (parent: Answer, _, context: MyContext): Promise => { + if (parent?.versionedCustomQuestionId) { + return await VersionedCustomQuestion.findById('Answer versionedCustomQuestion resolver', context, parent.versionedCustomQuestionId); + } + return null; + }, // The comments associated with the answer comments: async (parent: Answer, _, context: MyContext): Promise => { if (parent?.id) { diff --git a/src/resolvers/feedback.ts b/src/resolvers/feedback.ts index 60152f5a..d52afac6 100644 --- a/src/resolvers/feedback.ts +++ b/src/resolvers/feedback.ts @@ -108,15 +108,18 @@ export const resolvers: Resolvers = { if (isAdmin(context.token)) { // Check to see if planId exists in our records const plan = await Plan.findById(reference, context, planId); - if (!planId) { + if (!plan) { throw NotFoundError(`Plan with ID ${planId} not found`); } - // Get versionedTemplate associated with the plan - const versionedTemplate = await VersionedTemplate.findById(reference, context, plan.versionedTemplateId); + // Fetch the plan creator to compare affiliations + const planCreator = await User.findById(reference, context, plan.createdById); + if (!planCreator) { + throw NotFoundError(`User who created plan ${planId} not found`); + } // If the user is a superAdmin or an admin for the same affiliation - if (isSuperAdmin(context.token) || (isAdmin(context.token) && context.token.affiliationId === versionedTemplate.ownerId)) { + if (isSuperAdmin(context.token) || (isAdmin(context.token) && context.token.affiliationId === planCreator.affiliationId)) { // Check that user has permissions to access feedback const projectId = plan.projectId; diff --git a/src/resolvers/guidance.ts b/src/resolvers/guidance.ts index d8324009..748c74f8 100644 --- a/src/resolvers/guidance.ts +++ b/src/resolvers/guidance.ts @@ -95,7 +95,7 @@ export const resolvers: Resolvers = { // ============================================================================ guidanceSourcesForPlan: async ( _, - { planId, versionedSectionId, versionedQuestionId }, + { planId, versionedSectionId, versionedQuestionId, customSectionId, customQuestionId }, context: MyContext ): Promise => { const reference = 'guidanceSourcesForPlan resolver'; @@ -113,7 +113,9 @@ export const resolvers: Resolvers = { context, planId, versionedSectionId, - versionedQuestionId + versionedQuestionId, + customSectionId, + customQuestionId ); return sources; @@ -290,10 +292,10 @@ export const resolvers: Resolvers = { }); const created = await planGuidanceAffiliation.create(context); - if (created && created.hasErrors()) { + if (created && !created.hasErrors()) { return created; // Successfully created } else { - if (!created.errors['general']) { + if (!created?.errors?.general) { created.addError("general", "Unable to add plan guidance affiliation"); } return created; diff --git a/src/resolvers/plan.ts b/src/resolvers/plan.ts index eae6e8a6..c58f5d8b 100644 --- a/src/resolvers/plan.ts +++ b/src/resolvers/plan.ts @@ -49,6 +49,7 @@ export const resolvers: Resolvers = { const reference = 'plan resolver'; try { const plan = await Plan.findById(reference, context, planId); + if (!plan) { throw NotFoundError(`Plan with ID ${planId} not found`); } @@ -326,13 +327,13 @@ export const resolvers: Resolvers = { }, versionedSections: async (parent: Plan, _, context: MyContext): Promise => { if (parent?.id) { - return await PlanSectionProgress.findByPlanId('plan versionedSections resolver', context, parent.id); + return await PlanSectionProgress.findByPlanId('plan versionedSections resolver', context, parent.id, parent?.versionedTemplateId); } return []; }, progress: async (parent: Plan, _, context: MyContext): Promise => { if (parent?.id) { - return await PlanProgress.findByPlanId('plan progress resolver', context, parent.id); + return await PlanProgress.findByPlanId('plan progress resolver', context, parent.id, parent?.versionedTemplateId); } return null; }, @@ -348,11 +349,17 @@ export const resolvers: Resolvers = { }, PlanSearchResult: { - versionedSections: async (parent: PlanSearchResult, _, context: MyContext): Promise => { + versionedSections: async (parent, _, context: MyContext): Promise => { if (parent?.id) { - return await PlanSectionProgress.findByPlanId('planSearchresult versionedSections resolver', context, parent.id); + return await PlanSectionProgress.findByPlanId( + 'planSearchresult versionedSections resolver', + context, + parent.id, + parent?.versionedTemplateId + ); } return []; } - }, + } + } diff --git a/src/resolvers/project.ts b/src/resolvers/project.ts index c9b3f58f..f088efb7 100644 --- a/src/resolvers/project.ts +++ b/src/resolvers/project.ts @@ -37,7 +37,7 @@ import { PaginationOptionsForOffsets, PaginationType } from '../types/general'; -import {saveMaDMPVersion} from "../services/planService"; +import { saveMaDMPVersion } from "../services/planService"; export const resolvers: Resolvers = { Query: { @@ -51,8 +51,8 @@ export const resolvers: Resolvers = { try { if (isAuthorized(context.token)) { const pagOpts = !isNullOrUndefined(paginationOptions) && paginationOptions.type === PaginationType.OFFSET - ? paginationOptions as PaginationOptionsForOffsets - : { ...paginationOptions, type: PaginationType.CURSOR } as PaginationOptionsForCursors; + ? paginationOptions as PaginationOptionsForOffsets + : { ...paginationOptions, type: PaginationType.CURSOR } as PaginationOptionsForCursors; return await ProjectSearchResult.search( reference, @@ -117,6 +117,7 @@ export const resolvers: Resolvers = { if (isNullOrUndefined(project)) { throw NotFoundError(); } + if (await hasPermissionOnProject(context, project, ProjectCollaboratorAccessLevel.COMMENT)) { return project; } @@ -132,7 +133,7 @@ export const resolvers: Resolvers = { searchExternalProjects: async (_, { input }, context: MyContext): Promise => { const reference = 'external project search resolver'; - const { affiliationId , awardId, awardName, awardYear, piNames } = input; + const { affiliationId, awardId, awardName, awardYear, piNames } = input; try { if (isAuthorized(context.token)) { @@ -179,7 +180,7 @@ export const resolvers: Resolvers = { const reference = 'addProject resolver'; try { if (isAuthorized(context.token)) { - const newProject = new Project({title, isTestProject}); + const newProject = new Project({ title, isTestProject }); const created = await newProject.create(context); if (!isNullOrUndefined(created)) { @@ -282,7 +283,7 @@ export const resolvers: Resolvers = { }, // Import project from an external data source - projectImport: async (_, {input}, context) => { + projectImport: async (_, { input }, context) => { const reference = 'updateProject resolver'; try { if (isAuthorized(context.token)) { @@ -308,11 +309,11 @@ export const resolvers: Resolvers = { for (const fund of input.funding) { const newFunding = new ProjectFunding(fund); const fundingAdded = await newFunding.create(context, projectId); - if(!fundingAdded){ + if (!fundingAdded) { addFundingErrors.push(`Funding(affiliationId=${newFunding.affiliationId})`); } } - if(addFundingErrors.length > 0){ + if (addFundingErrors.length > 0) { const msg = `Unable to add fundings to project: ${addFundingErrors.join(', ')}`; context.logger.error(prepareObjectForLogs({ projectId }), msg); updatedProject.addError('fundings', msg) @@ -326,13 +327,13 @@ export const resolvers: Resolvers = { const newMember = new ProjectMember(contrib); context.logger.debug(`${reference}: add project member`); const memberAdded = await newMember.create(context, projectId); - if(!memberAdded){ + if (!memberAdded) { addMemberErrors.push(`Member(affiliationId=${newMember.affiliationId}, givenName=${newMember.givenName}, surName=${newMember.surName}, orcid=${newMember.orcid}, email=${newMember.email})`); } else { // Add member role context.logger.debug(`${reference}: add member role`); const role = await MemberRole.defaultRole(context, reference); - if(!role){ + if (!role) { context.logger.error(`${reference}: could not find default role`); } else { context.logger.debug(`${reference}: add ${role.label} to member ${memberAdded.id}`); @@ -344,12 +345,12 @@ export const resolvers: Resolvers = { } } - if(addMemberErrors.length > 0){ + if (addMemberErrors.length > 0) { const msg = `Unable to add members to project: ${addMemberErrors.join(', ')}`; context.logger.error(prepareObjectForLogs({ projectId }), msg); updatedProject.addError('members', msg) } - if(addMemberRoleErrors.length > 0){ + if (addMemberRoleErrors.length > 0) { const msg = `Unable to add default member roles: ${addMemberRoleErrors.join(', ')}` context.logger.error(prepareObjectForLogs({ projectId }), msg); updatedProject.addError('memberRoles', msg) diff --git a/src/resolvers/questionCustomization.ts b/src/resolvers/questionCustomization.ts index ad39f348..0c1a6489 100644 --- a/src/resolvers/questionCustomization.ts +++ b/src/resolvers/questionCustomization.ts @@ -454,31 +454,100 @@ export const resolvers: Resolvers = { context: MyContext ): Promise => { const ref = 'moveCustomQuestion resolver'; - const { customQuestionId, sectionType, sectionId, pinnedQuestionType, pinnedQuestionId } = input; + const { customQuestionId, sectionType, sectionId, pinnedQuestionType, pinnedQuestionId, direction } = input; + const customization: CustomQuestion = await CustomQuestion.findById(ref, context, customQuestionId); - const customization: CustomQuestion = await CustomQuestion.findById( - ref, - context, - customQuestionId - ); if (!customization) throw NotFoundError(); - // Fetch the parent template customization and verify the user has access const parent: TemplateCustomization = await getValidatedCustomization( + ref, context, customization.templateCustomizationId + ); + + const newPinType: PinnedQuestionTypeEnum = PinnedQuestionTypeEnum[pinnedQuestionType]; + const newSectionType = PinnedSectionTypeEnum[sectionType]; + const newPinnedQuestionType = isNullOrUndefined(newPinType) ? null : newPinType; + const newPinnedQuestionId = isNullOrUndefined(pinnedQuestionId) ? null : pinnedQuestionId; + + // Check if another question already occupies this exact position + const occupant = await CustomQuestion.findByPosition( ref, context, - customization.templateCustomizationId + parent.id, + newSectionType, + sectionId, + newPinnedQuestionType, + newPinnedQuestionId ); - const newPinType: PinnedQuestionTypeEnum = PinnedQuestionTypeEnum[pinnedQuestionType]; - // Section info cannot be null, but pinned question info can - customization.sectionType = PinnedSectionTypeEnum[sectionType]; + // Save customization's original position before any changes + const originalSectionType = customization.sectionType; + const originalSectionId = customization.sectionId; + const originalPinnedQuestionType = customization.pinnedQuestionType; + const originalPinnedQuestionId = customization.pinnedQuestionId; + + if (occupant && occupant.id !== customQuestionId) { + // Step 1: Temporarily free A's slot to a temporary position that won't conflict with any existing question + customization.pinnedQuestionType = null; + customization.pinnedQuestionId = null; + const tempMoved = await customization.update(context); + if (tempMoved.hasErrors()) { + throw new Error(`Failed to temporarily move question: ${JSON.stringify(tempMoved.errors)}`); + } + + // Step 2: Place occupant depending on direction (UP or DOWN). When A moves down to sit where B was, + // then B gets re-pinned from BASE to A. When A moves up to sit where B was, then B gets pinned from BASE to A's original position. + if (direction === 'DOWN') { + occupant.sectionType = newSectionType; + occupant.sectionId = sectionId; + occupant.pinnedQuestionType = PinnedQuestionTypeEnum.CUSTOM; + occupant.pinnedQuestionId = customQuestionId; + } else { + occupant.sectionType = originalSectionType; + occupant.sectionId = originalSectionId; + occupant.pinnedQuestionType = originalPinnedQuestionType; + occupant.pinnedQuestionId = originalPinnedQuestionId; + } + + const swapped = await occupant.update(context); + if (swapped.hasErrors()) { + throw new Error(`Failed to swap occupant: ${JSON.stringify(swapped.errors)}`); + } + } else if (direction === 'UP') { + // No occupant at target, but something may be chained after A + const tailQuestion = await CustomQuestion.findByPosition( + ref, context, parent.id, + originalSectionType, originalSectionId, + PinnedQuestionTypeEnum.CUSTOM, customQuestionId + ); + + if (tailQuestion) { + // Must temporarily free A's current slot FIRST before B can move into it + customization.pinnedQuestionType = null; + customization.pinnedQuestionId = null; + const tempMoved = await customization.update(context); + if (tempMoved.hasErrors()) { + throw new Error(`Failed to temporarily move question: ${JSON.stringify(tempMoved.errors)}`); + } + + // Now B can safely move to A's vacated slot + tailQuestion.sectionType = originalSectionType; + tailQuestion.sectionId = originalSectionId; + tailQuestion.pinnedQuestionType = originalPinnedQuestionType; + tailQuestion.pinnedQuestionId = originalPinnedQuestionId; + const reanch = await tailQuestion.update(context); + if (reanch.hasErrors()) { + throw new Error(`Failed to re-anchor tail question: ${JSON.stringify(reanch.errors)}`); + } + } + } + + // Step 3: Move A into its final target position + customization.sectionType = newSectionType; customization.sectionId = sectionId; - customization.pinnedQuestionType = isNullOrUndefined(newPinType) ? null : newPinType; - customization.pinnedQuestionId = isNullOrUndefined(pinnedQuestionId) ? null : pinnedQuestionId; - const moved: CustomQuestion = await customization.update(context); + customization.pinnedQuestionType = newPinnedQuestionType; + customization.pinnedQuestionId = newPinnedQuestionId; + const moved = await customization.update(context); - // If it was successfully moved, update the parent's isDirty flag if (moved && !moved.hasErrors() && !parent.isDirty) { await markTemplateCustomizationAsDirty(ref, context, parent.id, moved); } diff --git a/src/resolvers/templateCustomization.ts b/src/resolvers/templateCustomization.ts index df43bc31..d2b287fa 100644 --- a/src/resolvers/templateCustomization.ts +++ b/src/resolvers/templateCustomization.ts @@ -176,7 +176,7 @@ export const resolvers: Resolvers = { * @throws UnauthorizedError when the JWT token is not present * @throws InternalServerError when a fatal error has occurred */ - removeTemplateCustomization: authenticatedResolver( + removeTemplateCustomization: authenticatedResolver( 'removeTemplateCustomization resolver', UserRole.ADMIN, async ( diff --git a/src/resolvers/versionedQuestion.ts b/src/resolvers/versionedQuestion.ts index bdd3937d..dc1a9655 100644 --- a/src/resolvers/versionedQuestion.ts +++ b/src/resolvers/versionedQuestion.ts @@ -1,6 +1,7 @@ -import { Resolvers } from "../types"; +import { Resolvers, CustomizableObjectOwnership } from "../types"; import { MyContext } from "../context"; import { VersionedQuestion } from "../models/VersionedQuestion"; +import { VersionedCustomQuestion } from "../models/VersionedCustomQuestion"; import { Answer } from "../models/Answer"; import { AuthenticationError, ForbiddenError, InternalServerError } from "../utils/graphQLErrors"; import { VersionedQuestionCondition } from "../models/VersionedQuestionCondition"; @@ -9,32 +10,171 @@ import { isAuthorized } from "../services/authService"; import { GraphQLError } from "graphql"; import { normaliseDateTime } from "../utils/helpers"; import { VersionedTemplate } from "../models/VersionedTemplate"; +import { VersionedTemplateCustomization } from "../models/VersionedTemplateCustomization"; +import { VersionedQuestionCustomization } from "../models/VersionedQuestionCustomization"; import { Affiliation } from "../models/Affiliation"; -// Define new output structure for the published questions including whether they have an answer -type VersionedQuestionWithFilled = VersionedQuestion & { hasAnswer: boolean }; + +interface PublishedQuestionResult { + id: number; + questionText: string; + requirementText?: string; + guidanceText?: string; + sampleText?: string; + required: boolean; + hasAnswer: boolean; + questionType: CustomizableObjectOwnership; + // Type-specific IDs — one will always be present depending on questionType + versionedQuestionId?: number; // present when questionType === 'BASE' + customQuestionId?: number; // present when questionType === 'CUSTOM' +} + export const resolvers: Resolvers = { Query: { - // return all published questions for the specified versioned section - publishedQuestions: async (_, { planId, versionedSectionId }, context: MyContext): Promise => { + // return all published questions for the specified versioned section. Returns both base and custom questions, and + // includes a flag for if the question has an answer for the specified plan + publishedQuestions: async (_, { planId, versionedSectionId }, context: MyContext): Promise => { const reference = 'publishedQuestionsWithAnsweredFlag resolver'; try { if (isAuthorized(context.token)) { - const questions = await VersionedQuestion.findByVersionedSectionId(reference, context, versionedSectionId); + const [baseQuestions, customQuestions] = await Promise.all([ + VersionedQuestion.findByVersionedSectionId(reference, context, versionedSectionId), + VersionedCustomQuestion.findByVersionedSectionIdAndType(reference, context, versionedSectionId, 'BASE') + ]); + + const baseIds = baseQuestions.map(q => q.id); + const customIds = customQuestions.map(q => q.id); + + const [baseAnswers, customAnswers] = await Promise.all([ + Answer.findFilledAnswersByQuestionIds(reference, context, planId, baseIds), + Answer.findFilledAnswersByCustomQuestionIds(reference, context, planId, customIds) + ]); + + const baseAnswersMap = new Set(baseAnswers.map(a => a.versionedQuestionId)); + const customAnswersMap = new Set(customAnswers.map(a => a.versionedCustomQuestionId)); + + // Build ordered list starting with base questions + const ordered: PublishedQuestionResult[] = baseQuestions.map(q => ({ + id: q.id, + questionText: q.questionText, + requirementText: q.requirementText, + guidanceText: q.guidanceText, + sampleText: q.sampleText, + required: q.required, + hasAnswer: baseAnswersMap.has(q.id), + questionType: 'BASE' as CustomizableObjectOwnership, + versionedQuestionId: q.id, + customQuestionId: undefined, + })); + + // Sort custom questions by id (same as injectCustomQuestions) + const sortedCustom = [...customQuestions].sort((a, b) => a.id - b.id); + + // Splice each custom question in after its pinned question + for (const q of sortedCustom) { + const result: PublishedQuestionResult = { + id: q.id, + questionText: q.questionText, + requirementText: q.requirementText, + guidanceText: q.guidanceText, + sampleText: q.sampleText, + required: q.required, + hasAnswer: customAnswersMap.has(q.id), + questionType: 'CUSTOM' as CustomizableObjectOwnership, + versionedQuestionId: undefined, + customQuestionId: q.id, + }; + + if (q.pinnedVersionedQuestionId === null) { + // No pin — goes first + ordered.unshift(result); + } else { + const pinIdx = ordered.findIndex(o => + o.questionType === q.pinnedVersionedQuestionType && o.id === q.pinnedVersionedQuestionId + ); + if (pinIdx !== -1) { + ordered.splice(pinIdx + 1, 0, result); + } else { + // Pinned question not found — append to end + ordered.push(result); + } + } + } + + return ordered; + } + // Unauthorized + throw context?.token ? ForbiddenError() : AuthenticationError(); + } catch (err) { + if (err instanceof GraphQLError) throw err; + + context.logger.error(prepareObjectForLogs(err), `Failure in ${reference}`); + throw InternalServerError(); + } + }, + + // This only returns custom questions for a specified custom section, and include a flag for if the question has an answer for the specified plan + publishedCustomQuestions: async (_, { planId, versionedCustomSectionId }, context: MyContext): Promise => { + const reference = 'publishedCustomQuestionsWithAnsweredFlag resolver'; + try { + if (isAuthorized(context.token)) { + const questions = await VersionedCustomQuestion.findByVersionedCustomSectionId( + reference, context, versionedCustomSectionId + ); - // Fetch answers for the questions const questionIds = questions.map(q => q.id); - const answers = await Answer.findFilledAnswersByQuestionIds(reference, context, planId, questionIds); + const answers = await Answer.findFilledAnswersByCustomQuestionIds( + reference, context, planId, questionIds + ); + + const answersMap = new Set(answers.map(a => a.versionedCustomQuestionId)); - // Map the answers to the questions - const answersMap = new Set(answers.map(a => a.versionedQuestionId)); - return questions.map(question => ({ + return questions.map(q => ({ + id: q.id, + questionText: q.questionText, + requirementText: q.requirementText, + guidanceText: q.guidanceText, + sampleText: q.sampleText, + required: q.required, + hasAnswer: answersMap.has(q.id), + questionType: 'CUSTOM' as const, + versionedQuestionId: undefined, + customQuestionId: q.id, + json: q.json, + })); + } + throw context?.token ? ForbiddenError() : AuthenticationError(); + } catch (err) { + if (err instanceof GraphQLError) throw err; + context.logger.error(prepareObjectForLogs(err), `Failure in ${reference}`); + throw InternalServerError(); + } + }, + + // Return the VersionedQuestion for the specified versionedQuestionId which includes customization + // sample text, guidance text, and info on the org that customized it + publishedQuestion: async (_, { versionedQuestionId }, context: MyContext) => { + const reference = 'publishedQuestion resolver'; + try { + if (isAuthorized(context?.token)) { + const [question, customization] = await Promise.all([ + VersionedQuestion.findById(reference, context, versionedQuestionId), + VersionedQuestionCustomization.findActiveByTemplateAffiliationAndQuestion( + reference, context, context.token.affiliationId, versionedQuestionId + ), + ]); + + if (!question) return null; + + return { ...question, - hasAnswer: answersMap.has(question.id), - })) as VersionedQuestionWithFilled[]; + customizationId: customization?.id ?? null, + customizationGuidanceText: customization?.guidanceText ?? null, + customizationSampleText: customization?.sampleText ?? null, + customizationAffiliationId: customization ? context.token.affiliationId : null, + }; } - // Unauthorized! throw context?.token ? ForbiddenError() : AuthenticationError(); } catch (err) { if (err instanceof GraphQLError) throw err; @@ -44,12 +184,11 @@ export const resolvers: Resolvers = { } }, - publishedQuestion: async (_, { versionedQuestionId }, context: MyContext): Promise => { - const reference = 'publishedQuestion resolver'; + publishedCustomQuestion: async (_, { versionedCustomQuestionId }, context: MyContext): Promise => { + const reference = 'publishedCustomQuestion resolver'; try { if (isAuthorized(context?.token)) { - // Grab the versionedSection so we can get the section, and then the templateId - return await VersionedQuestion.findById(reference, context, versionedQuestionId); + return await VersionedCustomQuestion.findById(reference, context, versionedCustomQuestionId); } throw context?.token ? ForbiddenError() : AuthenticationError(); } catch (err) { @@ -61,6 +200,27 @@ export const resolvers: Resolvers = { }, }, + VersionedCustomQuestion: { + ownerAffiliation: async (parent: VersionedCustomQuestion, _, context: MyContext): Promise => { + const reference = 'VersionedCustomQuestion.ownerAffiliation resolver'; + const vtc = await VersionedTemplateCustomization.findById( + reference, context, parent.versionedTemplateCustomizationId + ); + if (!vtc?.currentVersionedTemplateId) return null; + const versionedTemplate = await VersionedTemplate.findById( + reference, context, vtc.currentVersionedTemplateId + ); + if (!versionedTemplate?.ownerId) return null; + return await Affiliation.findByURI(reference, context, versionedTemplate.ownerId); + }, + created: (parent: VersionedCustomQuestion) => { + return normaliseDateTime(parent.created); + }, + modified: (parent: VersionedCustomQuestion) => { + return normaliseDateTime(parent.modified); + }, + }, + VersionedQuestion: { // Chained resolver to return the VersionedQuestionConditions associated with this VersionedQuestion versionedQuestionConditions: async (parent: VersionedQuestion, _, context: MyContext): Promise => { @@ -84,6 +244,14 @@ export const resolvers: Resolvers = { versionedTemplate.ownerId ); }, + customizationOwnerAffiliation: async (parent: VersionedQuestion & { customizationAffiliationId?: string }, _, context: MyContext): Promise => { + if (!parent.customizationAffiliationId) return null; + return await Affiliation.findByURI( + 'VersionedQuestion.customizationOwnerAffiliation resolver', + context, + parent.customizationAffiliationId + ); + }, created: (parent: VersionedQuestion) => { return normaliseDateTime(parent.created); }, diff --git a/src/resolvers/versionedSection.ts b/src/resolvers/versionedSection.ts index 766adb1d..6dce1aef 100644 --- a/src/resolvers/versionedSection.ts +++ b/src/resolvers/versionedSection.ts @@ -1,16 +1,23 @@ import { Resolvers, VersionedSectionSearchResults } from "../types"; import { MyContext } from "../context"; import { VersionedSection, VersionedSectionSearchResult } from "../models/VersionedSection"; +import { VersionedCustomSection } from "../models/VersionedCustomSection"; import { Section } from "../models/Section"; import { Tag } from "../models/Tag"; import { VersionedTemplate } from "../models/VersionedTemplate"; -import { AuthenticationError, ForbiddenError, InternalServerError } from "../utils/graphQLErrors"; +import { + AuthenticationError, + ForbiddenError, + InternalServerError, + NotFoundError +} from "../utils/graphQLErrors"; import { VersionedQuestion } from "../models/VersionedQuestion"; import { prepareObjectForLogs } from "../logger"; import { GraphQLError } from "graphql"; import { PaginationOptionsForCursors, PaginationOptionsForOffsets, PaginationType } from "../types/general"; import { isNullOrUndefined, normaliseDateTime } from "../utils/helpers"; import { isAuthorized } from "../services/authService"; +import { VersionedCustomQuestion } from "../models/VersionedCustomQuestion"; export const resolvers: Resolvers = { Query: { @@ -32,8 +39,8 @@ export const resolvers: Resolvers = { const reference = 'publishedSections resolver'; try { const opts = !isNullOrUndefined(paginationOptions) && paginationOptions.type === PaginationType.OFFSET - ? paginationOptions as PaginationOptionsForOffsets - : { ...paginationOptions, type: PaginationType.CURSOR } as PaginationOptionsForCursors; + ? paginationOptions as PaginationOptionsForOffsets + : { ...paginationOptions, type: PaginationType.CURSOR } as PaginationOptionsForCursors; // Find published versionedSections with similar names for the current user return await VersionedSectionSearchResult.search(reference, context, term, opts); @@ -55,12 +62,47 @@ export const resolvers: Resolvers = { } catch (err) { if (err instanceof GraphQLError) throw err; + context.logger.error(prepareObjectForLogs(err), `Failure in ${reference}`); + throw InternalServerError(); + } + }, + // Get published, custom section + publishedCustomSection: async ( + _, + { customSectionId, planId }, + context: MyContext + ): Promise => { + const reference = 'publishedCustomSection resolver'; + try { + if (!isAuthorized(context.token)) { + throw context?.token ? ForbiddenError() : AuthenticationError(); + } + + const affiliationId = context.token.affiliationId; + if (!affiliationId) throw ForbiddenError(); + + const result = await VersionedCustomSection.findByPlanAndSectionId( + reference, + context, + planId, + customSectionId, + affiliationId + ); + + if (!result) { + throw NotFoundError(`Custom section with ID ${customSectionId} not found`); + } + + return result; + } catch (err) { + if (err instanceof GraphQLError) throw err; context.logger.error(prepareObjectForLogs(err), `Failure in ${reference}`); throw InternalServerError(); } } }, + VersionedSection: { // Chained resolver to fetch the Section related to VersionedSection section: async (parent: VersionedSection, _, context: MyContext): Promise
=> { @@ -88,5 +130,20 @@ export const resolvers: Resolvers = { modified: (parent: VersionedSection) => { return normaliseDateTime(parent.modified); } + }, + VersionedCustomSection: { + questions: async (parent: VersionedCustomSection, _, context: MyContext): Promise => { + return await VersionedCustomQuestion.findByVersionedCustomSectionId( + 'Chained VersionedCustomSection.questions', + context, + parent.id + ); + }, + created: (parent: VersionedCustomSection) => { + return normaliseDateTime(parent.created); + }, + modified: (parent: VersionedCustomSection) => { + return normaliseDateTime(parent.modified); + } } }; diff --git a/src/schemas/answer.ts b/src/schemas/answer.ts index 4db51e57..4fb7f7e7 100644 --- a/src/schemas/answer.ts +++ b/src/schemas/answer.ts @@ -6,7 +6,7 @@ export const typeDefs = gql` answers(projectId: Int!, planId: Int!, versionedSectionId: Int!): [Answer] "Get an answer by versionedQuestionId" - answerByVersionedQuestionId(projectId: Int!, planId: Int!, versionedQuestionId: Int!): Answer + answerByVersionedQuestionId(projectId: Int!, planId: Int!, versionedQuestionId: Int, versionedCustomQuestionId: Int): Answer "Get the specific answer" answer(projectId: Int!, answerId: Int!): Answer @@ -14,7 +14,7 @@ export const typeDefs = gql` extend type Mutation { "Answer a question" - addAnswer(planId: Int!, versionedSectionId: Int!, versionedQuestionId: Int!, json: String): Answer + addAnswer(planId: Int!,versionedSectionId: Int,versionedQuestionId: Int,versionedCustomSectionId: Int,versionedCustomQuestionId: Int,json: String): Answer "Edit an answer" updateAnswer(answerId: Int!, json: String): Answer "Add comment for an answer " @@ -44,6 +44,10 @@ export const typeDefs = gql` versionedSection: VersionedSection "The question in the template the answer is for" versionedQuestion: VersionedQuestion + "The custom section the answer is for" + versionedCustomSection: VersionedCustomSection + "The custom question the answer is for" + versionedCustomQuestion: VersionedCustomQuestion "The DMP that the answer belongs to" plan: Plan "The answer to the question" diff --git a/src/schemas/guidance.ts b/src/schemas/guidance.ts index 4e928759..cdd3ebaa 100644 --- a/src/schemas/guidance.ts +++ b/src/schemas/guidance.ts @@ -7,7 +7,7 @@ export const typeDefs = gql` "Get a specific Guidance item by ID" guidance(guidanceId: Int!): Guidance "Get all guidance sources for a plan, optionally filtered by section" - guidanceSourcesForPlan(planId: Int!, versionedSectionId: Int, versionedQuestionId: Int): [GuidanceSource!]! + guidanceSourcesForPlan(planId: Int!, versionedSectionId: Int, versionedQuestionId: Int, customSectionId: Int, customQuestionId: Int): [GuidanceSource!]! } extend type Mutation { diff --git a/src/schemas/plan.ts b/src/schemas/plan.ts index 45191f1d..3bf8021a 100644 --- a/src/schemas/plan.ts +++ b/src/schemas/plan.ts @@ -56,12 +56,18 @@ export const typeDefs = gql` templateTitle: String "The section search results" versionedSections: [PlanSectionProgress!] + "The versioned template id the plan is based on" + versionedTemplateId: Int } "The progress the user has made within a section of the plan" type PlanSectionProgress { + "Whether or not the section is a customization (i.e. added by the user and not part of the original template)" + sectionType: CustomizableObjectOwnership! "The id of the Section" - versionedSectionId: Int! + versionedSectionId: Int + "The custom section id if the section is a customization, otherwise null" + customSectionId: Int "The title of the section" title: String! "The display order of the section" diff --git a/src/schemas/questionCustomization.ts b/src/schemas/questionCustomization.ts index ffddd4c9..33e179c7 100644 --- a/src/schemas/questionCustomization.ts +++ b/src/schemas/questionCustomization.ts @@ -214,5 +214,15 @@ export const typeDefs = gql` pinnedQuestionType: CustomizableObjectOwnership "The identifier of the question this new custom question should appear after (null means it is the first question in the section)" pinnedQuestionId: Int + "Direction to move the question relative to the pinnedQuestion (UP or DOWN)" + direction: MoveCustomQuestionDirection! + } + + "Direction to move a custom question relative to the pinned question" + enum MoveCustomQuestionDirection { + "Move the question above the pinned question" + UP + "Move the question below the pinned question" + DOWN } `; diff --git a/src/schemas/versionedQuestion.ts b/src/schemas/versionedQuestion.ts index 9c05a096..3ea1eda1 100644 --- a/src/schemas/versionedQuestion.ts +++ b/src/schemas/versionedQuestion.ts @@ -3,13 +3,17 @@ import gql from 'graphql-tag'; export const typeDefs = gql` extend type Query { "Search for VersionedQuestions that belong to Section specified by sectionId and answer status for a plan" - publishedQuestions(planId: Int!, versionedSectionId: Int!): [VersionedQuestionWithFilled] + publishedQuestions(planId: Int!, versionedSectionId: Int!): [PublishedQuestion] + "Fetch all published custom questions for the specified versioned section" + publishedCustomQuestions(versionedCustomSectionId: Int!, planId: Int!): [PublishedQuestion] "Get a specific VersionedQuestion based on versionedQuestionId" publishedQuestion(versionedQuestionId: Int!): VersionedQuestion + "Get a specific published custom question based on versionedCustomQuestionId" + publishedCustomQuestion(versionedCustomQuestionId: Int!): VersionedCustomQuestion } -"A snapshot of a Question when it became published." -type VersionedQuestion { + "A snapshot of a Question when it became published." + type VersionedQuestion { "The unique identifer for the Object" id: Int "The user who created the Object" @@ -46,15 +50,38 @@ type VersionedQuestion { "To indicate whether the question is required to be completed" required: Boolean + "For question customization info" + customizationId: Int + customizationGuidanceText: String + customizationSampleText: String + customizationOwnerAffiliation: Affiliation + "The conditional logic associated with this VersionedQuestion" versionedQuestionConditions: [VersionedQuestionCondition!] "Owner affiliation for the question" ownerAffiliation: Affiliation -} + } -"A snapshot of a Question when it became published, but includes extra information about if answer is filled." -type VersionedQuestionWithFilled { - "The unique identifer for the Object" + "A collection of errors related to the VersionedQuestion" + type VersionedQuestionErrors { + "General error messages such as the object already exists" + general: String + + versionedTemplateId: String + versionedSectionId: String + questionId: String + displayOrder: String + json: String + questionText: String + requirementText: String + guidanceText: String + sampleText: String + versionedQuestionConditionIds: String + } + + "A snapshot of a CustomQuestion when the template customization was published." + type VersionedCustomQuestion { + "The unique identifier for the Object" id: Int "The user who created the Object" createdById: Int @@ -62,55 +89,83 @@ type VersionedQuestionWithFilled { created: String "The user who last modified the Object" modifiedById: Int - "The timestamp when the Object was last modifed" + "The timestamp when the Object was last modified" modified: String - "Errors associated with the Object" - errors: VersionedQuestionErrors - "The unique id of the VersionedTemplate that the VersionedQuestion belongs to" - versionedTemplateId: Int! - "The unique id of the VersionedSection that the VersionedQuestion belongs to" + "The VersionedTemplateCustomization this snapshot belongs to" + versionedTemplateCustomizationId: Int! + "The CustomQuestion this is a snapshot of" + customQuestionId: Int! + + "Whether this question is pinned inside a BASE or CUSTOM section" + versionedSectionType: String! + "The id of the section this question belongs to" versionedSectionId: Int! - "Id of the original question that was versioned" - questionId: Int! - "The display order of the VersionedQuestion" - displayOrder: Int - "The JSON representation of the question type" - json: String - "This will be used as a sort of title for the Question" - questionText: String - "Requirements associated with the Question" + + "The type of question this custom question is pinned after (null = first question in section)" + pinnedVersionedQuestionType: String + "The id of the question this custom question is pinned after" + pinnedVersionedQuestionId: Int + + "The question text" + questionText: String! + "The question JSON schema definition" + json: String! + "The requirement text for this question" requirementText: String - "Guidance to complete the question" + "Guidance to help the user answer this question" guidanceText: String - "Sample text to possibly provide a starting point or example to answer question" + "A sample answer for this question" sampleText: String - "Whether or not the sample text should be used as the default answer for this question" + "Whether the sample text should be pre-populated as the default answer" useSampleTextAsDefault: Boolean - "To indicate whether the question is required to be completed" + "Whether this question is required" required: Boolean - "The conditional logic associated with this VersionedQuestion" - versionedQuestionConditions: [VersionedQuestionCondition!] + "Owner affiliation for the question" + ownerAffiliation: Affiliation - "Indicates whether the question has an answer" - hasAnswer: Boolean -} + "Errors associated with the Object" + errors: VersionedCustomQuestionErrors + } -"A collection of errors related to the VersionedQuestion" -type VersionedQuestionErrors { - "General error messages such as the object already exists" - general: String - versionedTemplateId: String + "A collection of errors related to the VersionedCustomQuestion" + type VersionedCustomQuestionErrors { + "General error messages" + general: String + versionedTemplateCustomizationId: String + customQuestionId: String versionedSectionId: String - questionId: String - displayOrder: String - json: String questionText: String - requirementText: String - guidanceText: String - sampleText: String - versionedQuestionConditionIds: String + json: String } + + "A normalized question result covering both base and custom questions, with answer status" +type PublishedQuestion { + "The unique identifier for the Object" + id: Int + "Whether this is a BASE or CUSTOM question" + questionType: String + "Present when questionType is BASE" + versionedQuestionId: Int + "Present when questionType is CUSTOM" + customQuestionId: Int + "The JSON representation of the question type" + json: String + "This will be used as a sort of title for the Question" + questionText: String + "Requirements associated with the Question" + requirementText: String + "Guidance to complete the question" + guidanceText: String + "Sample text to possibly provide a starting point or example to answer question" + sampleText: String + "Whether or not the sample text should be used as the default answer for this question" + useSampleTextAsDefault: Boolean + "To indicate whether the question is required to be completed" + required: Boolean + "Indicates whether the question has an answer" + hasAnswer: Boolean +} ` diff --git a/src/schemas/versionedSection.ts b/src/schemas/versionedSection.ts index 115e893a..5d91ae17 100644 --- a/src/schemas/versionedSection.ts +++ b/src/schemas/versionedSection.ts @@ -8,6 +8,8 @@ export const typeDefs = gql` publishedSections(term: String!, paginationOptions: PaginationOptions): VersionedSectionSearchResults "Fetch a specific VersionedSection" publishedSection(versionedSectionId: Int!): VersionedSection + "Fetch a specific VersionedCustomSection for a plan - resolved via the caller's affiliation" + publishedCustomSection(customSectionId: Int!, planId: Int!): VersionedCustomSection "Get all of the best practice VersionedSection" bestPracticeSections: [VersionedSection] } @@ -113,4 +115,52 @@ export const typeDefs = gql` tagIds: String versionedQuestionIds: String } + + "A snapshot of a CustomSection when the template customization was published." + type VersionedCustomSection { + "The unique identifier for the Object" + id: Int + "The user who created the Object" + createdById: Int + "The timestamp when the Object was created" + created: String + "The user who last modified the Object" + modifiedById: Int + "The timestamp when the Object was last modified" + modified: String + + "The VersionedTemplateCustomization this snapshot belongs to" + versionedTemplateCustomizationId: Int! + "The CustomSection this is a snapshot of" + customSectionId: Int! + + "The type of base section this custom section is pinned after (null = prepend to template)" + pinnedVersionedSectionType: String + "The id of the base section this custom section is pinned after" + pinnedVersionedSectionId: Int + + "The custom section name/title" + name: String! + "The custom section introduction" + introduction: String + "Requirements that a user must consider in this section" + requirements: String + "Guidance to help the user with this section" + guidance: String + + "The custom questions associated with this VersionedCustomSection" + questions: [VersionedCustomQuestion!] + + "Errors associated with the Object" + errors: VersionedCustomSectionErrors + } + + "A collection of errors related to the VersionedCustomSection" + type VersionedCustomSectionErrors { + "General error messages" + general: String + versionedTemplateCustomizationId: String + customSectionId: String + name: String + } `; diff --git a/src/schemas/versionedTemplate.ts b/src/schemas/versionedTemplate.ts index 81192fff..59a733c7 100644 --- a/src/schemas/versionedTemplate.ts +++ b/src/schemas/versionedTemplate.ts @@ -79,6 +79,8 @@ export const typeDefs = gql` modifiedByName: String "The timestamp when the Template was last modified" modified: String + "The id of the template customization (undefined means the template has not been customized yet)" + versionedTemplateCustomizationId: Int } type CustomizableTemplateSearchResults implements PaginatedQueryResults { diff --git a/src/services/__tests__/guidanceService.spec.ts b/src/services/__tests__/guidanceService.spec.ts index d8095e91..f53a2448 100644 --- a/src/services/__tests__/guidanceService.spec.ts +++ b/src/services/__tests__/guidanceService.spec.ts @@ -3,7 +3,6 @@ import { buildMockContextWithToken } from "../../__mocks__/context"; import { logger } from "../../logger"; import { mockPlan, - mockUser, mockVersionedTemplate, mockUserSelections, mockBestPracticeGuidance, @@ -19,9 +18,13 @@ import { GuidanceGroup } from "../../models/GuidanceGroup"; import { PlanGuidance } from "../../models/Guidance"; import { VersionedGuidance } from "../../models/VersionedGuidance"; import { Plan } from "../../models/Plan"; -import { User } from "../../models/User"; import { VersionedTemplate } from "../../models/VersionedTemplate"; import { VersionedSection } from "../../models/VersionedSection"; +import { VersionedQuestion } from "../../models/VersionedQuestion"; +import { VersionedSectionCustomization } from "../../models/VersionedSectionCustomization"; +import { VersionedQuestionCustomization } from "../../models/VersionedQuestionCustomization"; +import { VersionedCustomSection } from "../../models/VersionedCustomSection"; +import { VersionedCustomQuestion } from "../../models/VersionedCustomQuestion"; import { Affiliation } from "../../models/Affiliation"; import { isSuperAdmin } from "../authService"; @@ -130,6 +133,30 @@ jest.mock("../../models/VersionedQuestion", () => ({ }, })); +jest.mock("../../models/VersionedSectionCustomization", () => ({ + VersionedSectionCustomization: { + findActiveByTemplateAffiliationAndSection: jest.fn(), + }, +})); + +jest.mock("../../models/VersionedQuestionCustomization", () => ({ + VersionedQuestionCustomization: { + findActiveByTemplateAffiliationAndQuestion: jest.fn(), + }, +})); + +jest.mock("../../models/VersionedCustomSection", () => ({ + VersionedCustomSection: { + findById: jest.fn(), + }, +})); + +jest.mock("../../models/VersionedCustomQuestion", () => ({ + VersionedCustomQuestion: { + findById: jest.fn(), + }, +})); + jest.mock("../../models/Affiliation", () => ({ Affiliation: { findByURI: jest.fn(), @@ -183,7 +210,7 @@ describe("hasPermissionOnGuidanceGroup", () => { context = await buildMockContextWithToken(logger); jest.clearAllMocks(); }); - + it("returns true if user is from the same org", async () => { (GuidanceGroup.findById as jest.Mock).mockResolvedValue({ affiliationId: "abc" }); const localContext = { token: { affiliationId: "abc" } }; @@ -219,16 +246,16 @@ describe("publishGuidanceGroup", () => { beforeEach(async () => { context = await buildMockContextWithToken(logger); jest.clearAllMocks(); - - group = { - id: 1, - bestPractice: true, - optionalSubset: false, - name: "g", - description: "desc", - update: jest.fn().mockResolvedValue({ hasErrors: () => false }) + + group = { + id: 1, + bestPractice: true, + optionalSubset: false, + name: "g", + description: "desc", + update: jest.fn().mockResolvedValue({ hasErrors: () => false }) }; - + VersionedGuidanceGroupMock.mockImplementation((data: Record) => ({ ...data, create: jest.fn().mockResolvedValue({ @@ -240,11 +267,11 @@ describe("publishGuidanceGroup", () => { hasErrors: () => false, update: jest.fn().mockResolvedValue({ hasErrors: () => false }), })); - + VersionedGuidanceGroupMock.findByGuidanceGroupId.mockResolvedValue([{ version: 1 }]); VersionedGuidanceGroupMock.deactivateAll.mockResolvedValue(true); GuidanceMock.findByGuidanceGroupId.mockResolvedValue([{ id: 1, tagId: 2, guidanceText: "txt" }]); - + VersionedGuidanceMock.mockImplementation((data: Record) => ({ ...data, create: jest.fn().mockResolvedValue({ hasErrors: () => false }), @@ -269,7 +296,7 @@ describe("publishGuidanceGroup", () => { modifiedById: 0, update: jest.fn(), }; - + await expect(guidanceService.publishGuidanceGroup(context, invalidGroup as GuidanceGroup)).rejects.toThrow(); }); @@ -279,7 +306,7 @@ describe("publishGuidanceGroup", () => { create: jest.fn().mockResolvedValue({ hasErrors: () => true }), hasErrors: () => true, })); - + await expect(guidanceService.publishGuidanceGroup(context, group as GuidanceGroup)).rejects.toThrow(); }); @@ -289,7 +316,7 @@ describe("publishGuidanceGroup", () => { create: jest.fn().mockResolvedValue({ hasErrors: () => true }), hasErrors: () => true, })); - + await expect(guidanceService.publishGuidanceGroup(context, group as GuidanceGroup)).rejects.toThrow(); }); }); @@ -300,7 +327,7 @@ describe("unpublishGuidanceGroup", () => { jest.clearAllMocks(); group = { id: 1 }; }); - + it("unpublishes a group and returns true", async () => { VersionedGuidanceGroupMock.deactivateAll.mockResolvedValue(true); const result = await guidanceService.unpublishGuidanceGroup(context, group as GuidanceGroup); @@ -319,7 +346,7 @@ describe("unpublishGuidanceGroup", () => { modifiedById: 0, update: jest.fn(), }; - + await expect(guidanceService.unpublishGuidanceGroup(context, invalidGroup as GuidanceGroup)).rejects.toThrow(); }); @@ -334,14 +361,14 @@ describe("markGuidanceGroupAsDirty", () => { context = await buildMockContextWithToken(logger); jest.clearAllMocks(); }); - + it("marks group as dirty if active version exists", async () => { const group = { isDirty: false, update: jest.fn().mockResolvedValue({}) }; (GuidanceGroup.findById as jest.Mock).mockResolvedValue(group); VersionedGuidanceGroupMock.findActiveByGuidanceGroupId.mockResolvedValue(true); - + await guidanceService.markGuidanceGroupAsDirty(context, 1); - + expect(group.isDirty).toBe(true); expect(group.update).toHaveBeenCalled(); }); @@ -355,20 +382,20 @@ describe("markGuidanceGroupAsDirty", () => { const group = { isDirty: false, update: jest.fn().mockResolvedValue({}) }; (GuidanceGroup.findById as jest.Mock).mockResolvedValue(group); VersionedGuidanceGroupMock.findActiveByGuidanceGroupId.mockResolvedValue(null); - + await expect(guidanceService.markGuidanceGroupAsDirty(context, 1)).resolves.toBeUndefined(); }); it("logs and throws on error", async () => { (GuidanceGroup.findById as jest.Mock).mockRejectedValue(new Error("fail")); - + await expect(guidanceService.markGuidanceGroupAsDirty(context, 1)).rejects.toThrow(); expect(context.logger.error).toHaveBeenCalled(); }); }); describe("getSectionTags", () => { - beforeEach(async() => { + beforeEach(async () => { context = await buildMockContextWithToken(logger); }); it("returns tags map", async () => { @@ -386,7 +413,7 @@ describe("getSectionTags", () => { }); describe("getSectionTagsMap", () => { - beforeEach(async() => { + beforeEach(async () => { context = await buildMockContextWithToken(logger); }); it("returns tags map", async () => { @@ -418,39 +445,39 @@ describe("getAffiliationsWithGuidanceForTemplate", () => { it("returns all affiliations with associated section tag guidance", async () => { const mockTemplate = { id: 1, ownerId: "https://ror.org/021nxhr62" }; (VersionedTemplate.findById as jest.Mock).mockResolvedValue(mockTemplate); - + // Mock section guidance check - has guidance (Affiliation.query as jest.Mock).mockResolvedValueOnce([{ count: 1 }]); // sections with guidance (Affiliation.query as jest.Mock).mockResolvedValueOnce([{ count: 0 }]); // questions without guidance - + // Mock getSectionTagsMap returning empty (no tags) (PlanGuidance.query as jest.Mock).mockResolvedValue([]); - + const result = await guidanceService.getAffiliationsWithGuidanceForTemplate(context, 1); - + expect(result).toEqual(["https://ror.org/021nxhr62"]); }); it("returns template owner URI if template has question guidance", async () => { const mockTemplate = { id: 1, ownerId: "https://ror.org/021nxhr62" }; (VersionedTemplate.findById as jest.Mock).mockResolvedValue(mockTemplate); - + // Mock question guidance check - has guidance (Affiliation.query as jest.Mock).mockResolvedValueOnce([{ count: 0 }]); // sections without guidance (Affiliation.query as jest.Mock).mockResolvedValueOnce([{ count: 1 }]); // questions with guidance - + // Mock getSectionTagsMap returning empty (no tags) (PlanGuidance.query as jest.Mock).mockResolvedValue([]); - + const result = await guidanceService.getAffiliationsWithGuidanceForTemplate(context, 1); - + expect(result).toEqual(["https://ror.org/021nxhr62"]); }); it("returns ALL affiliations that have the correct tag-based guidance", async () => { const mockTemplate = { id: 1, ownerId: "https://ror.org/021nxhr62" }; (VersionedTemplate.findById as jest.Mock).mockResolvedValue(mockTemplate); - + // Mock Affiliation.query calls in sequence (Affiliation.query as jest.Mock) .mockResolvedValueOnce([{ count: 0 }]) // sections check @@ -460,15 +487,15 @@ describe("getAffiliationsWithGuidanceForTemplate", () => { { affiliationId: "https://ror.org/01cwqze88" }, // NSF { affiliationId: "https://ror.org/03yrm5c26" } // NIH ]); - + // Mock getSectionTagIds returning tag IDs (PlanGuidance.query as jest.Mock).mockResolvedValue([ { tagId: 1 }, { tagId: 2 } ]); - + const result = await guidanceService.getAffiliationsWithGuidanceForTemplate(context, 1); - + expect(result).toEqual([ "https://ror.org/021nxhr62", "https://ror.org/01cwqze88", @@ -478,72 +505,72 @@ describe("getAffiliationsWithGuidanceForTemplate", () => { it("does not duplicate template owner URI if they match user affiliation", async () => { const mockTemplate = { id: 1, ownerId: "https://ror.org/021nxhr62" }; - const userContext = { - ...context, - token: { ...context.token, affiliationId: "https://ror.org/021nxhr62" } + const userContext = { + ...context, + token: { ...context.token, affiliationId: "https://ror.org/021nxhr62" } }; - + (VersionedTemplate.findById as jest.Mock).mockResolvedValue(mockTemplate); - + // Mock Affiliation.query calls in sequence (Affiliation.query as jest.Mock) .mockResolvedValueOnce([{ count: 1 }]) // sections check - has guidance .mockResolvedValueOnce([{ count: 0 }]) // questions check .mockResolvedValueOnce([{ count: 1 }]); // template owner tag-based guidance check // Should not check user affiliation since it's the same as template owner - + // Mock getSectionTagIds returning tag IDs (PlanGuidance.query as jest.Mock).mockResolvedValue([ { tagId: 1 } ]); - + const result = await guidanceService.getAffiliationsWithGuidanceForTemplate(userContext, 1); - + expect(result).toEqual(["https://ror.org/021nxhr62"]); }); it("returns [] if no section/question guidance and no tag-based guidance", async () => { const mockTemplate = { id: 1, ownerId: "https://ror.org/021nxhr62" }; (VersionedTemplate.findById as jest.Mock).mockResolvedValue(mockTemplate); - + // Mock Affiliation.query calls in sequence (Affiliation.query as jest.Mock) .mockResolvedValueOnce([{ count: 0 }]) // sections check .mockResolvedValueOnce([{ count: 0 }]) // questions check .mockResolvedValueOnce([{ count: 0 }]); // template owner tag-based guidance check - + // Mock getSectionTagIds returning tag IDs (PlanGuidance.query as jest.Mock).mockResolvedValue([ { tagId: 1 } ]); - + const result = await guidanceService.getAffiliationsWithGuidanceForTemplate(context, 1); - + expect(result).toEqual([]); }); it("returns [] if template has no tags and no section/question guidance", async () => { const mockTemplate = { id: 1, ownerId: "https://ror.org/021nxhr62" }; (VersionedTemplate.findById as jest.Mock).mockResolvedValue(mockTemplate); - + // Mock Affiliation.query calls in sequence (Affiliation.query as jest.Mock) .mockResolvedValueOnce([{ count: 0 }]) // sections check - no guidance .mockResolvedValueOnce([{ count: 0 }]); // questions check - no guidance - + // Mock getSectionTagIds returning no tags (PlanGuidance.query as jest.Mock).mockResolvedValue([]); - + const result = await guidanceService.getAffiliationsWithGuidanceForTemplate(context, 1); - + expect(result).toEqual([]); }); it("logs error and returns [] on exception", async () => { (VersionedTemplate.findById as jest.Mock).mockRejectedValue(new Error("Database error")); - + const result = await guidanceService.getAffiliationsWithGuidanceForTemplate(context, 1); - + expect(result).toEqual([]); expect(context.logger.error).toHaveBeenCalled(); }); @@ -555,41 +582,60 @@ describe("getGuidanceSourcesForPlan", () => { jest.clearAllMocks(); }); - it("returns [] if plan not found", async () => { + it("should return [] if plan not found", async () => { (Plan.findById as jest.Mock).mockResolvedValue(null); const result = await guidanceService.getGuidanceSourcesForPlan(context, 1); expect(result).toEqual([]); }); - it("returns [] if user not found", async () => { - (Plan.findById as jest.Mock).mockResolvedValue({ versionedTemplateId: 1 }); - (User.findById as jest.Mock).mockResolvedValue(null); + it("should return [] if versionedTemplateId is missing from plan", async () => { + (Plan.findById as jest.Mock).mockResolvedValue({ id: 1 }); // no versionedTemplateId const result = await guidanceService.getGuidanceSourcesForPlan(context, 1); expect(result).toEqual([]); }); - it("returns [] if tags are empty", async () => { - (Plan.findById as jest.Mock).mockResolvedValue({ versionedTemplateId: 1 }); - (User.findById as jest.Mock).mockResolvedValue({ affiliationId: "affil" }); - - (PlanGuidance.query as jest.Mock).mockResolvedValue([]); - - const result = await guidanceService.getGuidanceSourcesForPlan(context, 1); + it("should return [] when section has no tags and no guidanceText", async () => { + (Plan.findById as jest.Mock).mockResolvedValue({ id: 1, versionedTemplateId: 1 }); + (PlanGuidance.query as jest.Mock).mockResolvedValue([]); // empty tags + (VersionedSection.findById as jest.Mock).mockResolvedValue({ guidance: null }); + (VersionedSectionCustomization.findActiveByTemplateAffiliationAndSection as jest.Mock).mockResolvedValue(null); + (VersionedTemplate.findById as jest.Mock).mockResolvedValue({ ownerId: null }); + (PlanGuidance.findByPlanAndUserId as jest.Mock).mockResolvedValue([]); + + const result = await guidanceService.getGuidanceSourcesForPlan(context, 1, 1); + expect(result).toEqual([]); + }); + + it("should return [] if versionedQuestionId is provided but question not found", async () => { + (Plan.findById as jest.Mock).mockResolvedValue(mockPlan); + (VersionedQuestion.findById as jest.Mock).mockResolvedValue(null); + + const result = await guidanceService.getGuidanceSourcesForPlan( + context, mockPlan.id, undefined, 10 + ); expect(result).toEqual([]); }); - it("returns expected guidance sources for a populated plan", async () => { + it("should return [] if customSectionId is provided but custom section not found", async () => { + (Plan.findById as jest.Mock).mockResolvedValue(mockPlan); + (VersionedCustomSection.findById as jest.Mock).mockResolvedValue(null); + + const result = await guidanceService.getGuidanceSourcesForPlan( + context, mockPlan.id, undefined, undefined, 5 + ); + expect(result).toEqual([]); + }); + + it("should return expected guidance sources for a populated plan with versionedSectionId", async () => { (Plan.findById as jest.Mock).mockResolvedValue(mockPlan); - (User.findById as jest.Mock).mockResolvedValue(mockUser); (VersionedTemplate.findById as jest.Mock).mockResolvedValue(mockVersionedTemplate); - (VersionedSection.findById as jest.Mock).mockResolvedValue({ guidance: null }); // Mock section without guidance + (VersionedSection.findById as jest.Mock).mockResolvedValue({ guidance: null }); + (VersionedSectionCustomization.findActiveByTemplateAffiliationAndSection as jest.Mock).mockResolvedValue(null); (PlanGuidance.findByPlanAndUserId as jest.Mock).mockResolvedValue(mockUserSelections); - (PlanGuidance.query as jest.Mock).mockResolvedValue([ { id: 1, name: "Data Sharing" }, { id: 2, name: "Preservation" } ]); - (VersionedGuidance.findBestPracticeByTagIds as jest.Mock).mockResolvedValue(mockBestPracticeGuidance); (VersionedGuidance.findByAffiliationAndTagIds as jest.Mock).mockImplementation((_, __, uri) => { if (uri === "https://ror.org/03yrm5c26") return Promise.resolve(mockTagBasedGuidanceCDL); @@ -597,7 +643,6 @@ describe("getGuidanceSourcesForPlan", () => { if (uri === "https://ror.org/01cwqze88") return Promise.resolve(mockTagBasedGuidanceNIH); return Promise.resolve([]); }); - (Affiliation.findByURI as jest.Mock).mockImplementation((_, __, uri) => { if (uri === "https://ror.org/03yrm5c26") return Promise.resolve(mockAffiliationCDL); if (uri === "https://ror.org/021nxhr62") return Promise.resolve(mockAffiliationNSF); @@ -605,31 +650,273 @@ describe("getGuidanceSourcesForPlan", () => { return Promise.resolve(null); }); - const result = await guidanceService.getGuidanceSourcesForPlan(context, mockPlan.id, 1); // Pass versionedSectionId + const result = await guidanceService.getGuidanceSourcesForPlan(context, mockPlan.id, 1); expect(result).toHaveLength(4); - - // Check that all expected sources are present (order may vary) expect(result).toEqual(expect.arrayContaining([ - expect.objectContaining({ - id: "bestPractice", - type: "BEST_PRACTICE", - orgURI: "bestPractice", - }), - expect.objectContaining({ - id: "affiliation-https://ror.org/03yrm5c26", - orgURI: "https://ror.org/03yrm5c26", - }), - expect.objectContaining({ - id: "affiliation-https://ror.org/021nxhr62", - type: "TEMPLATE_OWNER", - orgURI: "https://ror.org/021nxhr62", - }), - expect.objectContaining({ - id: "affiliation-https://ror.org/01cwqze88", - type: "USER_SELECTED", - orgURI: "https://ror.org/01cwqze88", - }), + expect.objectContaining({ id: "bestPractice", type: "BEST_PRACTICE" }), + expect.objectContaining({ id: "affiliation-https://ror.org/03yrm5c26" }), + expect.objectContaining({ id: "affiliation-https://ror.org/021nxhr62", type: "TEMPLATE_OWNER" }), + expect.objectContaining({ id: "affiliation-https://ror.org/01cwqze88", type: "USER_SELECTED" }), ])); }); + + it("should return guidance sources for the versionedQuestionId path", async () => { + (Plan.findById as jest.Mock).mockResolvedValue(mockPlan); + (VersionedQuestion.findById as jest.Mock).mockResolvedValue({ + id: 10, versionedSectionId: 5, guidanceText: null, + }); + (PlanGuidance.query as jest.Mock).mockResolvedValue([{ id: 1, name: "Data Sharing" }]); + (VersionedQuestionCustomization.findActiveByTemplateAffiliationAndQuestion as jest.Mock).mockResolvedValue(null); + (VersionedTemplate.findById as jest.Mock).mockResolvedValue(mockVersionedTemplate); + (PlanGuidance.findByPlanAndUserId as jest.Mock).mockResolvedValue([]); + (VersionedGuidance.findBestPracticeByTagIds as jest.Mock).mockResolvedValue(mockBestPracticeGuidance); + (VersionedGuidance.findByAffiliationAndTagIds as jest.Mock).mockResolvedValue([]); + + const result = await guidanceService.getGuidanceSourcesForPlan( + context, mockPlan.id, undefined, 10 + ); + + expect(VersionedQuestion.findById).toHaveBeenCalled(); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ id: "bestPractice", type: "BEST_PRACTICE" }); + }); + + it("should return template owner source when section has guidanceText and no tags", async () => { + (Plan.findById as jest.Mock).mockResolvedValue(mockPlan); + (PlanGuidance.query as jest.Mock).mockResolvedValue([]); // no tags + (VersionedSection.findById as jest.Mock).mockResolvedValue({ guidance: "Template-level guidance" }); + (VersionedSectionCustomization.findActiveByTemplateAffiliationAndSection as jest.Mock).mockResolvedValue(null); + (VersionedTemplate.findById as jest.Mock).mockResolvedValue(mockVersionedTemplate); + (PlanGuidance.findByPlanAndUserId as jest.Mock).mockResolvedValue([ + { affiliationId: mockVersionedTemplate.ownerId }, + ]); + (Affiliation.findByURI as jest.Mock).mockResolvedValue(mockAffiliationNSF); + + const result = await guidanceService.getGuidanceSourcesForPlan(context, mockPlan.id, 1); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + type: "TEMPLATE_OWNER", + orgURI: mockVersionedTemplate.ownerId, + hasGuidance: true, + }); + expect(result[0].items[0].guidanceText).toEqual("Template-level guidance"); + }); + + it("should include USER_SELECTED empty pill sources for user selections with no guidance when no tags", async () => { + (Plan.findById as jest.Mock).mockResolvedValue(mockPlan); + (PlanGuidance.query as jest.Mock).mockResolvedValue([]); // no tags + (VersionedSection.findById as jest.Mock).mockResolvedValue({ guidance: null }); + (VersionedSectionCustomization.findActiveByTemplateAffiliationAndSection as jest.Mock).mockResolvedValue(null); + (VersionedTemplate.findById as jest.Mock).mockResolvedValue({ ownerId: null }); + (PlanGuidance.findByPlanAndUserId as jest.Mock).mockResolvedValue([ + { affiliationId: "https://ror.org/01cwqze88" }, + ]); + (Affiliation.findByURI as jest.Mock).mockResolvedValue(mockAffiliationNIH); + + const result = await guidanceService.getGuidanceSourcesForPlan(context, mockPlan.id, 1); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + type: "USER_SELECTED", + orgURI: "https://ror.org/01cwqze88", + items: [], + hasGuidance: false, + }); + }); + + it("should prepend section customization guidanceText to user affiliation items", async () => { + const userAffiliationUri = "https://ror.org/03yrm5c26"; // CDL + const localContext = { ...context, token: { ...context.token, affiliationId: userAffiliationUri } }; + + (Plan.findById as jest.Mock).mockResolvedValue(mockPlan); + (PlanGuidance.query as jest.Mock).mockResolvedValue([{ id: 1, name: "Data Sharing" }]); + (VersionedSection.findById as jest.Mock).mockResolvedValue({ guidance: null }); + (VersionedSectionCustomization.findActiveByTemplateAffiliationAndSection as jest.Mock).mockResolvedValue({ + guidance: "Customized section guidance", + }); + (VersionedTemplate.findById as jest.Mock).mockResolvedValue(mockVersionedTemplate); + (PlanGuidance.findByPlanAndUserId as jest.Mock).mockResolvedValue([ + { affiliationId: userAffiliationUri }, + ]); + (VersionedGuidance.findBestPracticeByTagIds as jest.Mock).mockResolvedValue([]); + (VersionedGuidance.findByAffiliationAndTagIds as jest.Mock).mockResolvedValue([ + { tagId: 1, guidanceText: "CDL tag guidance" }, + ]); + (Affiliation.findByURI as jest.Mock).mockResolvedValue(mockAffiliationCDL); + + const result = await guidanceService.getGuidanceSourcesForPlan( + localContext as MyContext, mockPlan.id, 1 + ); + + const userSource = result.find(s => s.id === `affiliation-${userAffiliationUri}`); + expect(userSource).toBeDefined(); + expect(userSource.type).toEqual("USER_AFFILIATION"); + expect(userSource.items[0].guidanceText).toEqual("Customized section guidance"); + }); + + it("should not prepend guidanceText to template owner items when customSectionId is used", async () => { + const localContext = { ...context, token: { ...context.token, affiliationId: "https://unrelated.org" } }; + + (Plan.findById as jest.Mock).mockResolvedValue(mockPlan); + (VersionedCustomSection.findById as jest.Mock).mockResolvedValue({ + id: 5, guidance: "Custom section guidance", + }); + (PlanGuidance.query as jest.Mock).mockResolvedValue([{ id: 1, name: "Data Sharing" }]); + (VersionedTemplate.findById as jest.Mock).mockResolvedValue(mockVersionedTemplate); + (PlanGuidance.findByPlanAndUserId as jest.Mock).mockResolvedValue([ + { affiliationId: mockVersionedTemplate.ownerId }, + ]); + (VersionedGuidance.findBestPracticeByTagIds as jest.Mock).mockResolvedValue([]); + (VersionedGuidance.findByAffiliationAndTagIds as jest.Mock).mockResolvedValue([ + { tagId: 1, guidanceText: "NSF tag guidance" }, + ]); + (Affiliation.findByURI as jest.Mock).mockResolvedValue(mockAffiliationNSF); + + const result = await guidanceService.getGuidanceSourcesForPlan( + localContext as MyContext, mockPlan.id, undefined, undefined, 5 + ); + + const templateOwnerSource = result.find(s => s.type === "TEMPLATE_OWNER"); + expect(templateOwnerSource).toBeDefined(); + // guidanceText must NOT be prepended for template owner when customSectionId is provided + expect(templateOwnerSource.items).toHaveLength(1); + expect(templateOwnerSource.items[0].guidanceText).toEqual("NSF tag guidance"); + }); + + it("should return [] if customQuestionId is provided but custom question not found", async () => { + (Plan.findById as jest.Mock).mockResolvedValue(mockPlan); + (VersionedCustomQuestion.findById as jest.Mock).mockResolvedValue(null); + + const result = await guidanceService.getGuidanceSourcesForPlan( + context, mockPlan.id, undefined, undefined, undefined, 99 + ); + expect(result).toEqual([]); + }); + + it("should use section tags when customQuestionId refers to a BASE-section question", async () => { + (Plan.findById as jest.Mock).mockResolvedValue(mockPlan); + (VersionedCustomQuestion.findById as jest.Mock).mockResolvedValue({ + id: 42, + versionedSectionType: 'BASE', + versionedSectionId: 7, + guidanceText: null, + }); + // getSectionTags is called with sectionId 7 — PlanGuidance.query receives ["7"] + (PlanGuidance.query as jest.Mock).mockResolvedValue([{ id: 1, name: "Data Sharing" }]); + (VersionedTemplate.findById as jest.Mock).mockResolvedValue(mockVersionedTemplate); + (PlanGuidance.findByPlanAndUserId as jest.Mock).mockResolvedValue([]); + (VersionedGuidance.findBestPracticeByTagIds as jest.Mock).mockResolvedValue( + mockBestPracticeGuidance + ); + (VersionedGuidance.findByAffiliationAndTagIds as jest.Mock).mockResolvedValue([]); + + const result = await guidanceService.getGuidanceSourcesForPlan( + context, mockPlan.id, undefined, undefined, undefined, 42 + ); + + expect(VersionedCustomQuestion.findById).toHaveBeenCalled(); + // getSectionTags uses the versionedSectionId (7), not the versionedTemplateId (973) + expect((PlanGuidance.query as jest.Mock).mock.calls[0][2]).toEqual(["7"]); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ id: "bestPractice", type: "BEST_PRACTICE" }); + }); + + it("should use template-wide tags when customQuestionId refers to a CUSTOM-section question", async () => { + (Plan.findById as jest.Mock).mockResolvedValue(mockPlan); + (VersionedCustomQuestion.findById as jest.Mock).mockResolvedValue({ + id: 42, + versionedSectionType: 'CUSTOM', + versionedSectionId: 7, + guidanceText: null, + }); + // getSectionTagsMap is called with versionedTemplateId — PlanGuidance.query receives ["973"] + (PlanGuidance.query as jest.Mock).mockResolvedValue([{ id: 2, name: "Preservation" }]); + (VersionedTemplate.findById as jest.Mock).mockResolvedValue(mockVersionedTemplate); + (PlanGuidance.findByPlanAndUserId as jest.Mock).mockResolvedValue([]); + (VersionedGuidance.findBestPracticeByTagIds as jest.Mock).mockResolvedValue( + mockBestPracticeGuidance + ); + (VersionedGuidance.findByAffiliationAndTagIds as jest.Mock).mockResolvedValue([]); + + const result = await guidanceService.getGuidanceSourcesForPlan( + context, mockPlan.id, undefined, undefined, undefined, 42 + ); + + expect(VersionedCustomQuestion.findById).toHaveBeenCalled(); + // getSectionTagsMap uses the versionedTemplateId (973), not the versionedSectionId + expect((PlanGuidance.query as jest.Mock).mock.calls[0][2]).toEqual(["973"]); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ id: "bestPractice", type: "BEST_PRACTICE" }); + }); + + it("should attribute guidanceText to user affiliation when customQuestionId is provided and no tags", async () => { + const userAffiliationUri = "https://ror.org/03yrm5c26"; // CDL + const localContext = { + ...context, + token: { ...context.token, affiliationId: userAffiliationUri }, + }; + + (Plan.findById as jest.Mock).mockResolvedValue(mockPlan); + (VersionedCustomQuestion.findById as jest.Mock).mockResolvedValue({ + id: 42, + versionedSectionType: 'BASE', + versionedSectionId: 7, + guidanceText: "Custom question guidance text", + }); + (PlanGuidance.query as jest.Mock).mockResolvedValue([]); // no tags + (VersionedTemplate.findById as jest.Mock).mockResolvedValue(mockVersionedTemplate); + (PlanGuidance.findByPlanAndUserId as jest.Mock).mockResolvedValue([ + { affiliationId: userAffiliationUri }, + ]); + (Affiliation.findByURI as jest.Mock).mockResolvedValue(mockAffiliationCDL); + + const result = await guidanceService.getGuidanceSourcesForPlan( + localContext as MyContext, mockPlan.id, undefined, undefined, undefined, 42 + ); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + type: "USER_AFFILIATION", + orgURI: userAffiliationUri, + hasGuidance: true, + }); + expect(result[0].items[0].guidanceText).toEqual("Custom question guidance text"); + }); + + it("should not prepend guidanceText to template owner items when customQuestionId is used", async () => { + const localContext = { + ...context, + token: { ...context.token, affiliationId: "https://unrelated.org" }, + }; + + (Plan.findById as jest.Mock).mockResolvedValue(mockPlan); + (VersionedCustomQuestion.findById as jest.Mock).mockResolvedValue({ + id: 42, + versionedSectionType: 'BASE', + versionedSectionId: 7, + guidanceText: "Custom question guidance", + }); + (PlanGuidance.query as jest.Mock).mockResolvedValue([{ id: 1, name: "Data Sharing" }]); + (VersionedTemplate.findById as jest.Mock).mockResolvedValue(mockVersionedTemplate); + (PlanGuidance.findByPlanAndUserId as jest.Mock).mockResolvedValue([ + { affiliationId: mockVersionedTemplate.ownerId }, + ]); + (VersionedGuidance.findBestPracticeByTagIds as jest.Mock).mockResolvedValue([]); + (VersionedGuidance.findByAffiliationAndTagIds as jest.Mock).mockResolvedValue([ + { tagId: 1, guidanceText: "NSF tag guidance" }, + ]); + (Affiliation.findByURI as jest.Mock).mockResolvedValue(mockAffiliationNSF); + + const result = await guidanceService.getGuidanceSourcesForPlan( + localContext as MyContext, mockPlan.id, undefined, undefined, undefined, 42 + ); + + const templateOwnerSource = result.find(s => s.type === "TEMPLATE_OWNER"); + expect(templateOwnerSource).toBeDefined(); + // guidanceText must NOT be prepended to template owner when customQuestionId is provided + expect(templateOwnerSource.items).toHaveLength(1); + expect(templateOwnerSource.items[0].guidanceText).toEqual("NSF tag guidance"); + }); }); diff --git a/src/services/__tests__/templateCustomizationPublishHelpers.spec.ts b/src/services/__tests__/templateCustomizationPublishHelpers.spec.ts new file mode 100644 index 00000000..9d0e728e --- /dev/null +++ b/src/services/__tests__/templateCustomizationPublishHelpers.spec.ts @@ -0,0 +1,304 @@ +import { MyContext } from "../../context"; +import { VersionedTemplateCustomization } from "../../models/VersionedTemplateCustomization"; +import { VersionedSection } from "../../models/VersionedSection"; +import { VersionedQuestion } from "../../models/VersionedQuestion"; +import { VersionedCustomSection } from "../../models/VersionedCustomSection"; +import { VersionedCustomQuestion } from "../../models/VersionedCustomQuestion"; +import { CustomSection, PinnedSectionTypeEnum } from "../../models/CustomSection"; +import { CustomQuestion } from "../../models/CustomQuestion"; +import { SectionCustomization } from "../../models/SectionCustomization"; +import { QuestionCustomization } from "../../models/QuestionCustomization"; +import { VersionedSectionCustomization } from "../../models/VersionedSectionCustomization"; +import { VersionedQuestionCustomization } from "../../models/VersionedQuestionCustomization"; +import { + PublishableCustomization, + snapshotCustomizationChildren, + rollbackPublishedSnapshot, +} from "../templateCustomizationPublishHelpers"; +import { User, UserRole } from "../../models/User"; +import casual from "casual"; +import { buildMockContextWithToken } from "../../__mocks__/context"; +import { logger } from "../../logger"; + +jest.mock("../../models/VersionedTemplateCustomization"); +jest.mock("../../models/VersionedSection"); +jest.mock("../../models/VersionedQuestion"); +jest.mock("../../models/VersionedCustomSection"); +jest.mock("../../models/VersionedCustomQuestion"); +jest.mock("../../models/VersionedSectionCustomization"); +jest.mock("../../models/VersionedQuestionCustomization"); +jest.mock("../../models/CustomSection"); +jest.mock("../../models/CustomQuestion"); +jest.mock("../../models/SectionCustomization"); +jest.mock("../../models/QuestionCustomization"); + +describe("templateCustomizationPublishHelpers", () => { + let mockContext: MyContext; + const reference = "test-reference"; + + beforeEach(async () => { + jest.clearAllMocks(); + const user = new User({ + id: casual.integer(1, 999), + givenName: casual.first_name, + surName: casual.last_name, + role: UserRole.ADMIN, + affiliationId: casual.url, + }); + (user.getEmail as jest.Mock) = jest.fn().mockResolvedValue(casual.email); + mockContext = await buildMockContextWithToken(logger, user); + }); + + describe("snapshotCustomizationChildren", () => { + let customization: PublishableCustomization; + let created: VersionedTemplateCustomization; + + beforeEach(() => { + customization = { + id: 1, + currentVersionedTemplateId: 10, + addError: jest.fn(), + hasErrors: jest.fn().mockReturnValue(false), + }; + created = new VersionedTemplateCustomization({ id: 99 }); + }); + + it("should do nothing when there are no custom sections, questions, or customizations", async () => { + (CustomSection.findByCustomizationId as jest.Mock).mockResolvedValue([]); + (CustomQuestion.findByCustomizationAndSectionType as jest.Mock).mockResolvedValue([]); + (SectionCustomization.findByCustomizationId as jest.Mock).mockResolvedValue([]); + (QuestionCustomization.findByCustomizationId as jest.Mock).mockResolvedValue([]); + + await snapshotCustomizationChildren(reference, mockContext, customization, created); + + expect(customization.addError).not.toHaveBeenCalled(); + }); + + it("should add error when versioning a custom section fails", async () => { + const mockSection = { id: 5, name: "My Section" }; + (CustomSection.findByCustomizationId as jest.Mock).mockResolvedValue([mockSection]); + (CustomQuestion.findByCustomizationAndSectionId as jest.Mock).mockResolvedValue([]); + (CustomQuestion.findByCustomizationAndSectionType as jest.Mock).mockResolvedValue([]); + (SectionCustomization.findByCustomizationId as jest.Mock).mockResolvedValue([]); + (QuestionCustomization.findByCustomizationId as jest.Mock).mockResolvedValue([]); + + const failedSection = new VersionedCustomSection({ errors: { general: "DB error" } }); + (failedSection.hasErrors as jest.Mock) = jest.fn().mockReturnValue(true); + jest.spyOn(VersionedCustomSection.prototype, "create").mockResolvedValue(failedSection); + + await snapshotCustomizationChildren(reference, mockContext, customization, created); + + expect(customization.addError).toHaveBeenCalledWith( + "general", + `Unable to version custom section: ${mockSection.name}` + ); + }); + + it("should add error when versioning a custom question in a custom section fails", async () => { + const mockSection = { id: 5, name: "My Section" }; + const mockQuestion = { + id: 10, + sectionType: PinnedSectionTypeEnum.CUSTOM, + sectionId: 5, + pinnedQuestionType: null, + pinnedQuestionId: null, + questionText: "Q?", + json: "{}", + requirementText: null, + guidanceText: null, + sampleText: null, + useSampleTextAsDefault: false, + required: false, + }; + (CustomSection.findByCustomizationId as jest.Mock).mockResolvedValue([mockSection]); + (CustomQuestion.findByCustomizationAndSectionId as jest.Mock).mockResolvedValue([mockQuestion]); + (CustomQuestion.findByCustomizationAndSectionType as jest.Mock).mockResolvedValue([]); + (SectionCustomization.findByCustomizationId as jest.Mock).mockResolvedValue([]); + (QuestionCustomization.findByCustomizationId as jest.Mock).mockResolvedValue([]); + + const okSection = new VersionedCustomSection({ id: 50 }); + (okSection.hasErrors as jest.Mock) = jest.fn().mockReturnValue(false); + jest.spyOn(VersionedCustomSection.prototype, "create").mockResolvedValue(okSection); + const failedQuestion = new VersionedCustomQuestion({ errors: { general: "DB error" } }); + (failedQuestion.hasErrors as jest.Mock) = jest.fn().mockReturnValue(true); + jest.spyOn(VersionedCustomQuestion.prototype, "create").mockResolvedValue(failedQuestion); + + await snapshotCustomizationChildren(reference, mockContext, customization, created); + + expect(customization.addError).toHaveBeenCalledWith( + "general", + `Unable to version custom question in section: ${mockSection.name}` + ); + }); + + it("should add error when versioning a section customization and versioned section lookup fails", async () => { + const mockSectionCust = { id: 7, sectionId: 20, guidance: "Some guidance" }; + (CustomSection.findByCustomizationId as jest.Mock).mockResolvedValue([]); + (CustomQuestion.findByCustomizationAndSectionType as jest.Mock).mockResolvedValue([]); + (SectionCustomization.findByCustomizationId as jest.Mock).mockResolvedValue([mockSectionCust]); + (QuestionCustomization.findByCustomizationId as jest.Mock).mockResolvedValue([]); + (VersionedSection.query as jest.Mock).mockResolvedValue([]); + + await snapshotCustomizationChildren(reference, mockContext, customization, created); + + expect(customization.addError).toHaveBeenCalledWith( + "general", + `Unable to find versioned section for sectionId: ${mockSectionCust.sectionId}` + ); + }); + + it("should add error when versioning a question customization and versioned question lookup fails", async () => { + const mockQuestionCust = { id: 8, questionId: 30, guidanceText: null, sampleText: null }; + (CustomSection.findByCustomizationId as jest.Mock).mockResolvedValue([]); + (CustomQuestion.findByCustomizationAndSectionType as jest.Mock).mockResolvedValue([]); + (SectionCustomization.findByCustomizationId as jest.Mock).mockResolvedValue([]); + (QuestionCustomization.findByCustomizationId as jest.Mock).mockResolvedValue([mockQuestionCust]); + (VersionedQuestion.query as jest.Mock).mockResolvedValue([]); + + await snapshotCustomizationChildren(reference, mockContext, customization, created); + + expect(customization.addError).toHaveBeenCalledWith( + "general", + `Unable to find versioned question for questionId: ${mockQuestionCust.questionId}` + ); + }); + + it("should add error when versioning a BASE custom question fails", async () => { + const mockQuestion = { + id: 20, + sectionType: PinnedSectionTypeEnum.BASE, + sectionId: 7, + pinnedQuestionType: null, + pinnedQuestionId: null, + questionText: "Q?", + json: "{}", + requirementText: null, + guidanceText: null, + sampleText: null, + useSampleTextAsDefault: false, + required: false, + }; + (CustomSection.findByCustomizationId as jest.Mock).mockResolvedValue([]); + (CustomQuestion.findByCustomizationAndSectionType as jest.Mock).mockResolvedValue([mockQuestion]); + (SectionCustomization.findByCustomizationId as jest.Mock).mockResolvedValue([]); + (QuestionCustomization.findByCustomizationId as jest.Mock).mockResolvedValue([]); + + const failedQuestion = new VersionedCustomQuestion({ errors: { general: "DB error" } }); + (failedQuestion.hasErrors as jest.Mock) = jest.fn().mockReturnValue(true); + jest.spyOn(VersionedCustomQuestion.prototype, "create").mockResolvedValue(failedQuestion); + + await snapshotCustomizationChildren(reference, mockContext, customization, created); + + expect(customization.addError).toHaveBeenCalledWith( + "general", + `Unable to version custom question for base section id: ${mockQuestion.sectionId}` + ); + }); + + it("should add error when VersionedSectionCustomization creation fails after section lookup succeeds", async () => { + const mockSectionCust = { id: 7, sectionId: 20, guidance: "Some guidance" }; + (CustomSection.findByCustomizationId as jest.Mock).mockResolvedValue([]); + (CustomQuestion.findByCustomizationAndSectionType as jest.Mock).mockResolvedValue([]); + (SectionCustomization.findByCustomizationId as jest.Mock).mockResolvedValue([mockSectionCust]); + (QuestionCustomization.findByCustomizationId as jest.Mock).mockResolvedValue([]); + (VersionedSection.query as jest.Mock).mockResolvedValue([{ id: 100 }]); + + const failedSectionCust = new VersionedSectionCustomization({ errors: { general: "DB error" } }); + (failedSectionCust.hasErrors as jest.Mock) = jest.fn().mockReturnValue(true); + jest.spyOn(VersionedSectionCustomization.prototype, "create").mockResolvedValue(failedSectionCust); + + await snapshotCustomizationChildren(reference, mockContext, customization, created); + + expect(customization.addError).toHaveBeenCalledWith( + "general", + `Unable to version section customization for sectionId: ${mockSectionCust.sectionId}` + ); + }); + + it("should add error when VersionedQuestionCustomization creation fails after question lookup succeeds", async () => { + const mockQuestionCust = { id: 8, questionId: 30, guidanceText: null, sampleText: null }; + (CustomSection.findByCustomizationId as jest.Mock).mockResolvedValue([]); + (CustomQuestion.findByCustomizationAndSectionType as jest.Mock).mockResolvedValue([]); + (SectionCustomization.findByCustomizationId as jest.Mock).mockResolvedValue([]); + (QuestionCustomization.findByCustomizationId as jest.Mock).mockResolvedValue([mockQuestionCust]); + (VersionedQuestion.query as jest.Mock).mockResolvedValue([{ id: 200 }]); + + const failedQuestionCust = new VersionedQuestionCustomization({ errors: { general: "DB error" } }); + (failedQuestionCust.hasErrors as jest.Mock) = jest.fn().mockReturnValue(true); + jest.spyOn(VersionedQuestionCustomization.prototype, "create").mockResolvedValue(failedQuestionCust); + + await snapshotCustomizationChildren(reference, mockContext, customization, created); + + expect(customization.addError).toHaveBeenCalledWith( + "general", + `Unable to version question customization for questionId: ${mockQuestionCust.questionId}` + ); + }); + + it("should successfully snapshot all children including section and question customizations", async () => { + const mockSectionCust = { id: 7, sectionId: 20, guidance: "Some guidance" }; + const mockQuestionCust = { id: 8, questionId: 30, guidanceText: null, sampleText: null }; + (CustomSection.findByCustomizationId as jest.Mock).mockResolvedValue([]); + (CustomQuestion.findByCustomizationAndSectionType as jest.Mock).mockResolvedValue([]); + (SectionCustomization.findByCustomizationId as jest.Mock).mockResolvedValue([mockSectionCust]); + (QuestionCustomization.findByCustomizationId as jest.Mock).mockResolvedValue([mockQuestionCust]); + (VersionedSection.query as jest.Mock).mockResolvedValue([{ id: 100 }]); + (VersionedQuestion.query as jest.Mock).mockResolvedValue([{ id: 200 }]); + + const okSectionCust = new VersionedSectionCustomization({ id: 70 }); + (okSectionCust.hasErrors as jest.Mock) = jest.fn().mockReturnValue(false); + jest.spyOn(VersionedSectionCustomization.prototype, "create").mockResolvedValue(okSectionCust); + + const okQuestionCust = new VersionedQuestionCustomization({ id: 80 }); + (okQuestionCust.hasErrors as jest.Mock) = jest.fn().mockReturnValue(false); + jest.spyOn(VersionedQuestionCustomization.prototype, "create").mockResolvedValue(okQuestionCust); + + await snapshotCustomizationChildren(reference, mockContext, customization, created); + + expect(customization.addError).not.toHaveBeenCalled(); + }); + }); + + describe("rollbackPublishedSnapshot", () => { + it("should delete the snapshot and cascade to child rows without restoring a prior version", async () => { + (VersionedTemplateCustomization.delete as jest.Mock).mockResolvedValue(true); + + await rollbackPublishedSnapshot(mockContext, 99, undefined); + + expect(VersionedTemplateCustomization.delete).toHaveBeenCalledWith( + mockContext, + VersionedTemplateCustomization.tableName, + 99, + "rollbackPublishedSnapshot" + ); + expect(VersionedTemplateCustomization.findById).not.toHaveBeenCalled(); + }); + + it("should re-activate the prior published version when priorPublishedVersionId is provided", async () => { + (VersionedTemplateCustomization.delete as jest.Mock).mockResolvedValue(true); + + const priorVer = new VersionedTemplateCustomization({ id: 50, active: false }); + priorVer.update = jest.fn().mockResolvedValue({ ...priorVer, active: true }); + (VersionedTemplateCustomization.findById as jest.Mock).mockResolvedValue(priorVer); + + await rollbackPublishedSnapshot(mockContext, 99, 50); + + expect(VersionedTemplateCustomization.findById).toHaveBeenCalledWith( + "rollbackPublishedSnapshot", + mockContext, + 50 + ); + expect(priorVer.active).toBe(true); + expect(priorVer.update).toHaveBeenCalledWith(mockContext, true); + }); + + it("should not attempt to restore prior version when findById returns null", async () => { + (VersionedTemplateCustomization.delete as jest.Mock).mockResolvedValue(true); + (VersionedTemplateCustomization.findById as jest.Mock).mockResolvedValue(null); + + await rollbackPublishedSnapshot(mockContext, 99, 50); + + expect(VersionedTemplateCustomization.findById).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/services/__tests__/templateCustomizationService.spec.ts b/src/services/__tests__/templateCustomizationService.spec.ts index 1358ca4e..538367a0 100644 --- a/src/services/__tests__/templateCustomizationService.spec.ts +++ b/src/services/__tests__/templateCustomizationService.spec.ts @@ -1,7 +1,7 @@ import { MyContext } from "../../context"; import { TemplateCustomization, - TemplateCustomizationMigrationStatus + TemplateCustomizationMigrationStatus, } from "../../models/TemplateCustomization"; import { handleFunderTemplateRepublication, diff --git a/src/services/guidanceService.ts b/src/services/guidanceService.ts index 1481f382..3af771dc 100644 --- a/src/services/guidanceService.ts +++ b/src/services/guidanceService.ts @@ -7,18 +7,24 @@ import { VersionedGuidanceGroup } from "../models/VersionedGuidanceGroup"; import { VersionedGuidance } from "../models/VersionedGuidance"; import { VersionedSection } from "../models/VersionedSection"; import { VersionedQuestion } from "../models/VersionedQuestion"; +import { VersionedQuestionCustomization } from "../models/VersionedQuestionCustomization"; +import { VersionedSectionCustomization } from "../models/VersionedSectionCustomization"; +import { VersionedCustomSection } from "../models/VersionedCustomSection"; +import { VersionedCustomQuestion } from "../models/VersionedCustomQuestion"; import { Plan } from "../models/Plan"; import { VersionedTemplate } from "../models/VersionedTemplate"; import { prepareObjectForLogs } from "../logger"; import { getCurrentDate } from "../utils/helpers"; import { isSuperAdmin } from "./authService"; +import { GuidanceSourceType } from "../types"; + +const GuidanceSourceType = { + BEST_PRACTICE: 'BEST_PRACTICE' as const, + TEMPLATE_OWNER: 'TEMPLATE_OWNER' as const, + USER_AFFILIATION: 'USER_AFFILIATION' as const, + USER_SELECTED: 'USER_SELECTED' as const, +}; -export enum GuidanceSourceType { - BEST_PRACTICE = 'BEST_PRACTICE', - TEMPLATE_OWNER = 'TEMPLATE_OWNER', - USER_AFFILIATION = 'USER_AFFILIATION', - USER_SELECTED = 'USER_SELECTED' -} export interface GuidanceItem { id?: number; @@ -35,9 +41,9 @@ export interface GuidanceSource { items: GuidanceItem[]; hasGuidance: boolean; } -interface TagRow { - id: number; - name: string +interface TagRow { + id: number; + name: string }; @@ -288,7 +294,9 @@ export async function getGuidanceSourcesForPlan( context: MyContext, planId: number, versionedSectionId?: number, - versionedQuestionId?: number + versionedQuestionId?: number, + customSectionId?: number, + customQuestionId?: number ): Promise { const reference = 'getGuidanceSourcesForPlan'; @@ -334,11 +342,56 @@ export async function getGuidanceSourcesForPlan( // Get tags for the question's section tagsMap = await getSectionTags(context, question.versionedSectionId); guidanceText = question.guidanceText || null; // Question-level guidance + } else if (customQuestionId) { + // Custom question: guidance is owned by the user's institution + const customQuestion = await VersionedCustomQuestion.findById( + reference, context, customQuestionId + ); + if (!customQuestion) return []; + guidanceText = customQuestion.guidanceText || null; + // Get tags from the parent section (which may be BASE or CUSTOM) + if (customQuestion.versionedSectionType === 'BASE') { + tagsMap = await getSectionTags(context, customQuestion.versionedSectionId); + } else { + // CUSTOM parent section — fall back to template-wide tags + tagsMap = await getSectionTagsMap(context, versionedTemplateId); + } } else if (versionedSectionId) { // Otherwise, get tags and guidance for the section id provided tagsMap = await getSectionTags(context, versionedSectionId); const section = await VersionedSection.findById(reference, context, versionedSectionId); guidanceText = section?.guidance || null; // Section-level guidance + } else if (customSectionId) { + + // Custom sections have their own tags and guidance + const customSection = await VersionedCustomSection.findById(reference, context, customSectionId); + if (!customSection) return []; + guidanceText = customSection?.guidance || null; + tagsMap = await getSectionTagsMap(context, versionedTemplateId); + } + + + // Fetch any active customization guidance for this section/question for the user's affiliation. + // This is displayed alongside the template owner's guidance. + let customizationGuidanceText: string | null = null; + if (userAffiliationUri) { + if (versionedQuestionId) { + const questionCustomization = await VersionedQuestionCustomization + .findActiveByTemplateAffiliationAndQuestion( + reference, context, userAffiliationUri, versionedQuestionId + ); + customizationGuidanceText = questionCustomization?.guidanceText || null; + + } else if (versionedSectionId) { + const sectionCustomization = await VersionedSectionCustomization + .findActiveByTemplateAffiliationAndSection( + reference, context, userAffiliationUri, versionedSectionId + ); + + customizationGuidanceText = sectionCustomization?.guidance || null; + } + // customQuestionId: guidanceText is already on the VersionedCustomQuestion itself — + // no separate customization record exists for custom questions } // Get template owner info @@ -348,29 +401,71 @@ export async function getGuidanceSourcesForPlan( // If there are no tag ids, then just return the guidanceText from the template owner const sectionTagIds = Object.keys(tagsMap).map(Number); if (sectionTagIds.length === 0) { - // If there's guidance text, return it as template owner's guidance + const result: GuidanceSource[] = []; + + // Add primary guidance source (customization or template owner) if (guidanceText) { - if (templateOwnerUri) { + // Custom section/question guidance belongs to the institution, not the template owner + if ((customSectionId || customQuestionId) && userAffiliationUri) { + const affiliation = await Affiliation.findByURI(reference, context, userAffiliationUri); + if (affiliation) { + result.push({ + id: `customization-${userAffiliationUri}`, + type: GuidanceSourceType.USER_AFFILIATION, + label: affiliation.displayName || affiliation.name, + shortName: (affiliation.acronyms && affiliation.acronyms[0]) || + affiliation.displayName || affiliation.name, + orgURI: userAffiliationUri, + items: [{ guidanceText }], + hasGuidance: true + }); + } + // Base section/question guidance with no tags belongs to the template owner + } else if (templateOwnerUri) { const affiliation = await Affiliation.findByURI(reference, context, templateOwnerUri); if (affiliation) { - return [{ + result.push({ id: `affiliation-${templateOwnerUri}`, type: GuidanceSourceType.TEMPLATE_OWNER, label: affiliation.displayName || affiliation.name, shortName: (affiliation.acronyms && affiliation.acronyms[0]) || - affiliation.displayName || - affiliation.name, + affiliation.displayName || affiliation.name, orgURI: templateOwnerUri, - items: [{ - title: affiliation.displayName || affiliation.name, - guidanceText - }], + items: [{ title: affiliation.displayName || affiliation.name, guidanceText }], hasGuidance: true - }]; + }); } } } - return []; + + // Still load planGuidance selections so those orgs appear as pills *** + // This prevents the user from trying to re-add orgs that already have a planGuidance row + const userSelections = await PlanGuidance.findByPlanAndUserId(reference, context, planId, userId); + const processedOrgURIs = new Set(result.map(s => s.orgURI)); + + for (const selection of userSelections) { + const affiliationUri = selection.affiliationId; + if (!affiliationUri || processedOrgURIs.has(affiliationUri)) continue; + + const affiliation = await Affiliation.findByURI(reference, context, affiliationUri); + if (!affiliation) continue; + + // Include with empty items — they're selected but have no guidance for this custom section. + // They still need to appear as pills so the modal shows them as "already added". + result.push({ + id: `affiliation-${affiliationUri}`, + type: GuidanceSourceType.USER_SELECTED, + label: affiliation.displayName || affiliation.name, + shortName: (affiliation.acronyms && affiliation.acronyms[0]) || + affiliation.displayName || affiliation.name, + orgURI: affiliationUri, + items: [], + hasGuidance: false + }); + processedOrgURIs.add(affiliationUri); + } + + return result; // Includes all selected orgs } // Get user-selected affiliations from planGuidance table @@ -435,6 +530,17 @@ export async function getGuidanceSourcesForPlan( const items = groupGuidanceByTag(tagBasedGuidance, sectionTagIds, tagsMap); + // For custom sections/questions, the guidanceText IS the user's content — prepend it + // For versioned sections, customizationGuidanceText is the override — prepend it + const customGuidanceText = (customSectionId || customQuestionId) ? guidanceText : customizationGuidanceText; + if (customGuidanceText) { + items.unshift({ + title: affiliation.displayName || affiliation.name, + guidanceText: customGuidanceText + }); + } + + if (items.length > 0) { guidanceSources.push({ id: `affiliation-${userAffiliationUri}`, @@ -476,29 +582,32 @@ export async function getGuidanceSourcesForPlan( const items = groupGuidanceByTag(tagBasedGuidance, sectionTagIds, tagsMap); - // If there's section-level guidance, add it FIRST - if (guidanceText) { + // Only prepend section-level guidanceText for versioned sections/questions. + // For custom sections/questions, the content was created by the user's institution — + // the template owner has no guidance to contribute here. + if (guidanceText && !customSectionId && !customQuestionId) { items.unshift({ title: affiliation.displayName || affiliation.name, guidanceText }); } - if (items.length > 0) { - guidanceSources.push({ - id: `affiliation-${templateOwnerUri}`, - type: GuidanceSourceType.TEMPLATE_OWNER, - label: affiliation.displayName || affiliation.name, - shortName: (affiliation.acronyms && affiliation.acronyms[0]) || - affiliation.displayName || - affiliation.name, - orgURI: templateOwnerUri, - items, - hasGuidance: true - }); + // Always include template owner as a source, even with no guidance for a custom section + // so that the Guidance Panel can show "no guidance available" + guidanceSources.push({ + id: `affiliation-${templateOwnerUri}`, + type: GuidanceSourceType.TEMPLATE_OWNER, + label: affiliation.displayName || affiliation.name, + shortName: (affiliation.acronyms && affiliation.acronyms[0]) || + affiliation.displayName || + affiliation.name, + orgURI: templateOwnerUri, + items, + hasGuidance: true + }); + + processedOrgURIs.add(templateOwnerUri); - processedOrgURIs.add(templateOwnerUri); - } } } } diff --git a/src/services/templateCustomizationPublishHelpers.ts b/src/services/templateCustomizationPublishHelpers.ts new file mode 100644 index 00000000..4ac19cf1 --- /dev/null +++ b/src/services/templateCustomizationPublishHelpers.ts @@ -0,0 +1,236 @@ +import { MyContext } from "../context"; +import { VersionedTemplateCustomization } from "../models/VersionedTemplateCustomization"; +import { VersionedSection } from "../models/VersionedSection"; +import { VersionedQuestion } from "../models/VersionedQuestion"; +import { VersionedCustomSection } from "../models/VersionedCustomSection"; +import { VersionedCustomQuestion } from "../models/VersionedCustomQuestion"; +import { VersionedSectionCustomization } from "../models/VersionedSectionCustomization"; +import { VersionedQuestionCustomization } from "../models/VersionedQuestionCustomization"; +import { CustomSection, PinnedSectionTypeEnum } from "../models/CustomSection"; +import { CustomQuestion } from "../models/CustomQuestion"; +import { SectionCustomization } from "../models/SectionCustomization"; +import { QuestionCustomization } from "../models/QuestionCustomization"; + +/** + * A minimal interface representing the shape of a TemplateCustomization that + * the helpers need to read from and write errors onto. Using an interface here + * (rather than importing the class) avoids a circular dependency: + * TemplateCustomization → helpers → TemplateCustomization + */ +export interface PublishableCustomization { + id?: number; + currentVersionedTemplateId: number; + addError(field: string, message: string): void; + hasErrors(): boolean; +} + +/** + * Snapshot all child customization records into their versioned counterparts. + * Any failure adds an error onto the customization object rather than throwing. + * + * @param reference The reference string for logging. + * @param context The Apollo context. + * @param customization The TemplateCustomization being published. + * @param created The newly created VersionedTemplateCustomization snapshot. + */ +export const snapshotCustomizationChildren = async ( + reference: string, + context: MyContext, + customization: PublishableCustomization, + created: VersionedTemplateCustomization +): Promise => { + // Snapshot custom sections and their questions into versioned equivalents + const customSections = await CustomSection.findByCustomizationId( + reference, context, customization.id); + + for (const section of customSections) { + const versionedSection = new VersionedCustomSection({ + versionedTemplateCustomizationId: created.id, + customSectionId: section.id, + pinnedVersionedSectionType: section.pinnedSectionType, + pinnedVersionedSectionId: section.pinnedSectionId, + name: section.name, + introduction: section.introduction, + requirements: section.requirements, + guidance: section.guidance, + }); + const createdSection = await versionedSection.create(context); + + if (!createdSection || createdSection.hasErrors()) { + customization.addError('general', `Unable to version custom section: ${section.name}`); + continue; + } + + // Snapshot custom questions belonging to this custom section + const customQuestions = await CustomQuestion.findByCustomizationAndSectionId( + reference, context, customization.id, PinnedSectionTypeEnum.CUSTOM, section.id); + + for (const question of customQuestions) { + const versionedQuestion = new VersionedCustomQuestion({ + versionedTemplateCustomizationId: created.id, + customQuestionId: question.id, + versionedSectionType: question.sectionType, + versionedSectionId: question.sectionId, + pinnedVersionedQuestionType: question.pinnedQuestionType ?? null, + pinnedVersionedQuestionId: question.pinnedQuestionId ?? null, + questionText: question.questionText, + json: question.json, + requirementText: question.requirementText ?? null, + guidanceText: question.guidanceText ?? null, + sampleText: question.sampleText ?? null, + useSampleTextAsDefault: question.useSampleTextAsDefault ?? false, + required: question.required ?? false, + }); + const createdQuestion = await versionedQuestion.create(context); + + if (!createdQuestion || createdQuestion.hasErrors()) { + customization.addError( + 'general', + `Unable to version custom question in section: ${section.name}` + ); + } + } + } + + // Snapshot custom questions attached to BASE sections (not covered by the loop above) + const baseCustomQuestions = await CustomQuestion.findByCustomizationAndSectionType( + reference, context, customization.id, PinnedSectionTypeEnum.BASE); + + for (const question of baseCustomQuestions) { + const versionedQuestion = new VersionedCustomQuestion({ + versionedTemplateCustomizationId: created.id, + customQuestionId: question.id, + versionedSectionType: question.sectionType, + versionedSectionId: question.sectionId, + pinnedVersionedQuestionType: question.pinnedQuestionType ?? null, + pinnedVersionedQuestionId: question.pinnedQuestionId ?? null, + questionText: question.questionText, + json: question.json, + requirementText: question.requirementText ?? null, + guidanceText: question.guidanceText ?? null, + sampleText: question.sampleText ?? null, + useSampleTextAsDefault: question.useSampleTextAsDefault ?? false, + required: question.required ?? false, + }); + const createdQuestion = await versionedQuestion.create(context); + + if (!createdQuestion || createdQuestion.hasErrors()) { + customization.addError( + 'general', + `Unable to version custom question for base section id: ${question.sectionId}` + ); + } + } + + // Snapshot sectionCustomizations into versionedSectionCustomizations + const sectionCustomizations = await SectionCustomization.findByCustomizationId( + reference, context, customization.id); + + for (const sectionCust of sectionCustomizations) { + const versionedSectionRows = await VersionedSection.query( + context, + `SELECT id FROM versionedSections + WHERE sectionId = ? AND versionedTemplateId = ? LIMIT 1`, + [sectionCust.sectionId.toString(), customization.currentVersionedTemplateId.toString()], + reference + ); + + if (!versionedSectionRows?.length) { + customization.addError( + 'general', + `Unable to find versioned section for sectionId: ${sectionCust.sectionId}` + ); + continue; + } + + const versionedSectionCust = new VersionedSectionCustomization({ + versionedTemplateCustomizationId: created.id, + sectionCustomizationId: sectionCust.id, + versionedSectionId: versionedSectionRows[0].id, + guidance: sectionCust.guidance, + createdById: context.token?.id, + modifiedById: context.token?.id, + }); + const createdSectionCust = await versionedSectionCust.create(context); + + if (!createdSectionCust || createdSectionCust.hasErrors()) { + customization.addError( + 'general', + `Unable to version section customization for sectionId: ${sectionCust.sectionId}` + ); + } + } + + // Snapshot questionCustomizations into versionedQuestionCustomizations + const questionCustomizations = await QuestionCustomization.findByCustomizationId( + reference, context, customization.id); + + for (const questionCust of questionCustomizations) { + const versionedQuestionRows = await VersionedQuestion.query( + context, + `SELECT id FROM versionedQuestions + WHERE questionId = ? AND versionedTemplateId = ? LIMIT 1`, + [questionCust.questionId.toString(), customization.currentVersionedTemplateId.toString()], + reference + ); + + if (!versionedQuestionRows?.length) { + customization.addError( + 'general', + `Unable to find versioned question for questionId: ${questionCust.questionId}` + ); + continue; + } + + const versionedQuestionCust = new VersionedQuestionCustomization({ + versionedTemplateCustomizationId: created.id, + questionCustomizationId: questionCust.id, + versionedQuestionId: versionedQuestionRows[0].id, + guidanceText: questionCust.guidanceText ?? null, + sampleText: questionCust.sampleText ?? null, + createdById: context.token?.id, + modifiedById: context.token?.id, + }); + const createdQuestionCust = await versionedQuestionCust.create(context); + + if (!createdQuestionCust || createdQuestionCust.hasErrors()) { + customization.addError( + 'general', + `Unable to version question customization for questionId: ${questionCust.questionId}` + ); + } + } +}; + +/** + * Roll back an incomplete published snapshot by deleting the snapshot record. + * The child tables (versionedCustomSections, versionedCustomQuestions, + * versionedSectionCustomizations, versionedQuestionCustomizations) are cleaned + * up automatically via ON DELETE CASCADE on their versionedTemplateCustomizationId + * FK constraints. Also re-activates the prior published version if one existed. + * + * @param context The Apollo context. + * @param createdVersionId The id of the incomplete VersionedTemplateCustomization. + * @param priorPublishedVersionId The id of the version to re-activate, if any. + */ +export const rollbackPublishedSnapshot = async ( + context: MyContext, + createdVersionId: number, + priorPublishedVersionId: number | undefined +): Promise => { + const ref = 'rollbackPublishedSnapshot'; + await VersionedTemplateCustomization.delete( + context, + VersionedTemplateCustomization.tableName, + createdVersionId, + ref + ); + if (priorPublishedVersionId) { + const priorVer = await VersionedTemplateCustomization.findById( + ref, context, priorPublishedVersionId); + if (priorVer) { + priorVer.active = true; + await priorVer.update(context, true); + } + } +}; diff --git a/src/types.ts b/src/types.ts index 5b45d3af..f260fedb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -552,6 +552,10 @@ export type Answer = { modifiedById?: Maybe; /** The DMP that the answer belongs to */ plan?: Maybe; + /** The custom question the answer is for */ + versionedCustomQuestion?: Maybe; + /** The custom section the answer is for */ + versionedCustomSection?: Maybe; /** The question in the template the answer is for */ versionedQuestion?: Maybe; /** The question in the template the answer is for */ @@ -1307,10 +1311,19 @@ export type MetadataStandardSearchResults = PaginatedQueryResults & { totalCount?: Maybe; }; +/** Direction to move a custom question relative to the pinned question */ +export type MoveCustomQuestionDirection = + /** Move the question below the pinned question */ + | 'DOWN' + /** Move the question above the pinned question */ + | 'UP'; + /** Move a custom question to a different position within the section (null means move to the top of the section) */ export type MoveCustomQuestionInput = { /** the id of the custom question to move */ customQuestionId: Scalars['Int']['input']; + /** Direction to move the question relative to the pinnedQuestion (UP or DOWN) */ + direction: MoveCustomQuestionDirection; /** The identifier of the question this new custom question should appear after (null means it is the first question in the section) */ pinnedQuestionId?: InputMaybe; /** The type of the question this new custom question should appear after (null means it is the first question in the section) */ @@ -1600,8 +1613,10 @@ export type MutationAddAffiliationArgs = { export type MutationAddAnswerArgs = { json?: InputMaybe; planId: Scalars['Int']['input']; - versionedQuestionId: Scalars['Int']['input']; - versionedSectionId: Scalars['Int']['input']; + versionedCustomQuestionId?: InputMaybe; + versionedCustomSectionId?: InputMaybe; + versionedQuestionId?: InputMaybe; + versionedSectionId?: InputMaybe; }; @@ -2646,6 +2661,8 @@ export type PlanSearchResult = { title?: Maybe; /** The section search results */ versionedSections?: Maybe>; + /** The versioned template id the plan is based on */ + versionedTemplateId?: Maybe; /** The visibility/permission setting */ visibility?: Maybe; }; @@ -2655,8 +2672,12 @@ export type PlanSectionProgress = { __typename?: 'PlanSectionProgress'; /** The number of questions the user has answered */ answeredQuestions: Scalars['Int']['output']; + /** The custom section id if the section is a customization, otherwise null */ + customSectionId?: Maybe; /** The display order of the section */ displayOrder: Scalars['Int']['output']; + /** Whether or not the section is a customization (i.e. added by the user and not part of the original template) */ + sectionType: CustomizableObjectOwnership; /** Tags associated with the section */ tags?: Maybe>; /** The title of the section */ @@ -2664,7 +2685,7 @@ export type PlanSectionProgress = { /** The number of questions in the section */ totalQuestions: Scalars['Int']['output']; /** The id of the Section */ - versionedSectionId: Scalars['Int']['output']; + versionedSectionId?: Maybe; }; /** The status/state of the plan */ @@ -2993,6 +3014,35 @@ export type ProjectSearchResults = PaginatedQueryResults & { totalCount?: Maybe; }; +/** A normalized question result covering both base and custom questions, with answer status */ +export type PublishedQuestion = { + __typename?: 'PublishedQuestion'; + /** Present when questionType is CUSTOM */ + customQuestionId?: Maybe; + /** Guidance to complete the question */ + guidanceText?: Maybe; + /** Indicates whether the question has an answer */ + hasAnswer?: Maybe; + /** The unique identifier for the Object */ + id?: Maybe; + /** The JSON representation of the question type */ + json?: Maybe; + /** This will be used as a sort of title for the Question */ + questionText?: Maybe; + /** Whether this is a BASE or CUSTOM question */ + questionType?: Maybe; + /** To indicate whether the question is required to be completed */ + required?: Maybe; + /** Requirements associated with the Question */ + requirementText?: Maybe; + /** Sample text to possibly provide a starting point or example to answer question */ + sampleText?: Maybe; + /** Whether or not the sample text should be used as the default answer for this question */ + useSampleTextAsDefault?: Maybe; + /** Present when questionType is BASE */ + versionedQuestionId?: Maybe; +}; + export type PublishedTemplateMetaDataResults = { __typename?: 'PublishedTemplateMetaDataResults'; /** The available affiliations in the result set */ @@ -3126,10 +3176,16 @@ export type Query = { projectMembers?: Maybe>>; /** Search for VersionedQuestions that belong to Section specified by sectionId */ publishedConditionsForQuestion?: Maybe>>; + /** Get a specific published custom question based on versionedCustomQuestionId */ + publishedCustomQuestion?: Maybe; + /** Fetch all published custom questions for the specified versioned section */ + publishedCustomQuestions?: Maybe>>; + /** Fetch a specific VersionedCustomSection for a plan - resolved via the caller's affiliation */ + publishedCustomSection?: Maybe; /** Get a specific VersionedQuestion based on versionedQuestionId */ publishedQuestion?: Maybe; /** Search for VersionedQuestions that belong to Section specified by sectionId and answer status for a plan */ - publishedQuestions?: Maybe>>; + publishedQuestions?: Maybe>>; /** Fetch a specific VersionedSection */ publishedSection?: Maybe; /** Search for VersionedSection whose name contains the search term */ @@ -3241,7 +3297,8 @@ export type QueryAnswerArgs = { export type QueryAnswerByVersionedQuestionIdArgs = { planId: Scalars['Int']['input']; projectId: Scalars['Int']['input']; - versionedQuestionId: Scalars['Int']['input']; + versionedCustomQuestionId?: InputMaybe; + versionedQuestionId?: InputMaybe; }; @@ -3314,6 +3371,8 @@ export type QueryGuidanceGroupsArgs = { export type QueryGuidanceSourcesForPlanArgs = { + customQuestionId?: InputMaybe; + customSectionId?: InputMaybe; planId: Scalars['Int']['input']; versionedQuestionId?: InputMaybe; versionedSectionId?: InputMaybe; @@ -3443,6 +3502,23 @@ export type QueryPublishedConditionsForQuestionArgs = { }; +export type QueryPublishedCustomQuestionArgs = { + versionedCustomQuestionId: Scalars['Int']['input']; +}; + + +export type QueryPublishedCustomQuestionsArgs = { + planId: Scalars['Int']['input']; + versionedCustomSectionId: Scalars['Int']['input']; +}; + + +export type QueryPublishedCustomSectionArgs = { + customSectionId: Scalars['Int']['input']; + planId: Scalars['Int']['input']; +}; + + export type QueryPublishedQuestionArgs = { versionedQuestionId: Scalars['Int']['input']; }; @@ -5024,6 +5100,108 @@ export type UserSearchResults = PaginatedQueryResults & { totalCount?: Maybe; }; +/** A snapshot of a CustomQuestion when the template customization was published. */ +export type VersionedCustomQuestion = { + __typename?: 'VersionedCustomQuestion'; + /** The timestamp when the Object was created */ + created?: Maybe; + /** The user who created the Object */ + createdById?: Maybe; + /** The CustomQuestion this is a snapshot of */ + customQuestionId: Scalars['Int']['output']; + /** Errors associated with the Object */ + errors?: Maybe; + /** Guidance to help the user answer this question */ + guidanceText?: Maybe; + /** The unique identifier for the Object */ + id?: Maybe; + /** The question JSON schema definition */ + json: Scalars['String']['output']; + /** The timestamp when the Object was last modified */ + modified?: Maybe; + /** The user who last modified the Object */ + modifiedById?: Maybe; + /** Owner affiliation for the question */ + ownerAffiliation?: Maybe; + /** The id of the question this custom question is pinned after */ + pinnedVersionedQuestionId?: Maybe; + /** The type of question this custom question is pinned after (null = first question in section) */ + pinnedVersionedQuestionType?: Maybe; + /** The question text */ + questionText: Scalars['String']['output']; + /** Whether this question is required */ + required?: Maybe; + /** The requirement text for this question */ + requirementText?: Maybe; + /** A sample answer for this question */ + sampleText?: Maybe; + /** Whether the sample text should be pre-populated as the default answer */ + useSampleTextAsDefault?: Maybe; + /** The id of the section this question belongs to */ + versionedSectionId: Scalars['Int']['output']; + /** Whether this question is pinned inside a BASE or CUSTOM section */ + versionedSectionType: Scalars['String']['output']; + /** The VersionedTemplateCustomization this snapshot belongs to */ + versionedTemplateCustomizationId: Scalars['Int']['output']; +}; + +/** A collection of errors related to the VersionedCustomQuestion */ +export type VersionedCustomQuestionErrors = { + __typename?: 'VersionedCustomQuestionErrors'; + customQuestionId?: Maybe; + /** General error messages */ + general?: Maybe; + json?: Maybe; + questionText?: Maybe; + versionedSectionId?: Maybe; + versionedTemplateCustomizationId?: Maybe; +}; + +/** A snapshot of a CustomSection when the template customization was published. */ +export type VersionedCustomSection = { + __typename?: 'VersionedCustomSection'; + /** The timestamp when the Object was created */ + created?: Maybe; + /** The user who created the Object */ + createdById?: Maybe; + /** The CustomSection this is a snapshot of */ + customSectionId: Scalars['Int']['output']; + /** Errors associated with the Object */ + errors?: Maybe; + /** Guidance to help the user with this section */ + guidance?: Maybe; + /** The unique identifier for the Object */ + id?: Maybe; + /** The custom section introduction */ + introduction?: Maybe; + /** The timestamp when the Object was last modified */ + modified?: Maybe; + /** The user who last modified the Object */ + modifiedById?: Maybe; + /** The custom section name/title */ + name: Scalars['String']['output']; + /** The id of the base section this custom section is pinned after */ + pinnedVersionedSectionId?: Maybe; + /** The type of base section this custom section is pinned after (null = prepend to template) */ + pinnedVersionedSectionType?: Maybe; + /** The custom questions associated with this VersionedCustomSection */ + questions?: Maybe>; + /** Requirements that a user must consider in this section */ + requirements?: Maybe; + /** The VersionedTemplateCustomization this snapshot belongs to */ + versionedTemplateCustomizationId: Scalars['Int']['output']; +}; + +/** A collection of errors related to the VersionedCustomSection */ +export type VersionedCustomSectionErrors = { + __typename?: 'VersionedCustomSectionErrors'; + customSectionId?: Maybe; + /** General error messages */ + general?: Maybe; + name?: Maybe; + versionedTemplateCustomizationId?: Maybe; +}; + /** A snapshot of a Guidance item when its GuidanceGroup was published */ export type VersionedGuidance = { __typename?: 'VersionedGuidance'; @@ -5118,6 +5296,11 @@ export type VersionedQuestion = { created?: Maybe; /** The user who created the Object */ createdById?: Maybe; + customizationGuidanceText?: Maybe; + /** For question customization info */ + customizationId?: Maybe; + customizationOwnerAffiliation?: Maybe; + customizationSampleText?: Maybe; /** The display order of the VersionedQuestion */ displayOrder?: Maybe; /** Errors associated with the Object */ @@ -5232,49 +5415,6 @@ export type VersionedQuestionErrors = { versionedTemplateId?: Maybe; }; -/** A snapshot of a Question when it became published, but includes extra information about if answer is filled. */ -export type VersionedQuestionWithFilled = { - __typename?: 'VersionedQuestionWithFilled'; - /** The timestamp when the Object was created */ - created?: Maybe; - /** The user who created the Object */ - createdById?: Maybe; - /** The display order of the VersionedQuestion */ - displayOrder?: Maybe; - /** Errors associated with the Object */ - errors?: Maybe; - /** Guidance to complete the question */ - guidanceText?: Maybe; - /** Indicates whether the question has an answer */ - hasAnswer?: Maybe; - /** The unique identifer for the Object */ - id?: Maybe; - /** The JSON representation of the question type */ - json?: Maybe; - /** The timestamp when the Object was last modifed */ - modified?: Maybe; - /** The user who last modified the Object */ - modifiedById?: Maybe; - /** Id of the original question that was versioned */ - questionId: Scalars['Int']['output']; - /** This will be used as a sort of title for the Question */ - questionText?: Maybe; - /** To indicate whether the question is required to be completed */ - required?: Maybe; - /** Requirements associated with the Question */ - requirementText?: Maybe; - /** Sample text to possibly provide a starting point or example to answer question */ - sampleText?: Maybe; - /** Whether or not the sample text should be used as the default answer for this question */ - useSampleTextAsDefault?: Maybe; - /** The conditional logic associated with this VersionedQuestion */ - versionedQuestionConditions?: Maybe>; - /** The unique id of the VersionedSection that the VersionedQuestion belongs to */ - versionedSectionId: Scalars['Int']['output']; - /** The unique id of the VersionedTemplate that the VersionedQuestion belongs to */ - versionedTemplateId: Scalars['Int']['output']; -}; - /** A snapshot of a Section when it became published. */ export type VersionedSection = { __typename?: 'VersionedSection'; @@ -5457,6 +5597,8 @@ export type VersionedTemplateSearchResult = { templateId?: Maybe; /** The major.minor semantic version */ version?: Maybe; + /** The id of the template customization (undefined means the template has not been customized yet) */ + versionedTemplateCustomizationId?: Maybe; /** The template's availability setting: Public is available to everyone, Private only your affiliation */ visibility?: Maybe; }; @@ -5730,6 +5872,7 @@ export type ResolversTypes = { MetadataStandard: ResolverTypeWrapper; MetadataStandardErrors: ResolverTypeWrapper; MetadataStandardSearchResults: ResolverTypeWrapper; + MoveCustomQuestionDirection: MoveCustomQuestionDirection; MoveCustomQuestionInput: MoveCustomQuestionInput; MoveCustomSectionInput: MoveCustomSectionInput; Mutation: ResolverTypeWrapper>; @@ -5776,6 +5919,7 @@ export type ResolversTypes = { ProjectSearchResultFunding: ResolverTypeWrapper; ProjectSearchResultMember: ResolverTypeWrapper; ProjectSearchResults: ResolverTypeWrapper; + PublishedQuestion: ResolverTypeWrapper; PublishedTemplateMetaDataResults: ResolverTypeWrapper; PublishedTemplateSearchResults: ResolverTypeWrapper; Query: ResolverTypeWrapper>; @@ -5867,6 +6011,10 @@ export type ResolversTypes = { UserErrors: ResolverTypeWrapper; UserRole: UserRole; UserSearchResults: ResolverTypeWrapper; + VersionedCustomQuestion: ResolverTypeWrapper; + VersionedCustomQuestionErrors: ResolverTypeWrapper; + VersionedCustomSection: ResolverTypeWrapper; + VersionedCustomSectionErrors: ResolverTypeWrapper; VersionedGuidance: ResolverTypeWrapper; VersionedGuidanceErrors: ResolverTypeWrapper; VersionedGuidanceGroup: ResolverTypeWrapper; @@ -5877,7 +6025,6 @@ export type ResolversTypes = { VersionedQuestionConditionCondition: VersionedQuestionConditionCondition; VersionedQuestionConditionErrors: ResolverTypeWrapper; VersionedQuestionErrors: ResolverTypeWrapper; - VersionedQuestionWithFilled: ResolverTypeWrapper; VersionedSection: ResolverTypeWrapper; VersionedSectionErrors: ResolverTypeWrapper; VersionedSectionSearchResult: ResolverTypeWrapper; @@ -6005,6 +6152,7 @@ export type ResolversParentTypes = { ProjectSearchResultFunding: ProjectSearchResultFunding; ProjectSearchResultMember: ProjectSearchResultMember; ProjectSearchResults: ProjectSearchResults; + PublishedQuestion: PublishedQuestion; PublishedTemplateMetaDataResults: PublishedTemplateMetaDataResults; PublishedTemplateSearchResults: PublishedTemplateSearchResults; Query: Record; @@ -6083,6 +6231,10 @@ export type ResolversParentTypes = { UserEmailErrors: UserEmailErrors; UserErrors: UserErrors; UserSearchResults: UserSearchResults; + VersionedCustomQuestion: VersionedCustomQuestion; + VersionedCustomQuestionErrors: VersionedCustomQuestionErrors; + VersionedCustomSection: VersionedCustomSection; + VersionedCustomSectionErrors: VersionedCustomSectionErrors; VersionedGuidance: VersionedGuidance; VersionedGuidanceErrors: VersionedGuidanceErrors; VersionedGuidanceGroup: VersionedGuidanceGroup; @@ -6091,7 +6243,6 @@ export type ResolversParentTypes = { VersionedQuestionCondition: VersionedQuestionCondition; VersionedQuestionConditionErrors: VersionedQuestionConditionErrors; VersionedQuestionErrors: VersionedQuestionErrors; - VersionedQuestionWithFilled: VersionedQuestionWithFilled; VersionedSection: VersionedSection; VersionedSectionErrors: VersionedSectionErrors; VersionedSectionSearchResult: VersionedSectionSearchResult; @@ -6208,6 +6359,8 @@ export type AnswerResolvers, ParentType, ContextType>; modifiedById?: Resolver, ParentType, ContextType>; plan?: Resolver, ParentType, ContextType>; + versionedCustomQuestion?: Resolver, ParentType, ContextType>; + versionedCustomSection?: Resolver, ParentType, ContextType>; versionedQuestion?: Resolver, ParentType, ContextType>; versionedSection?: Resolver, ParentType, ContextType>; }; @@ -6618,7 +6771,7 @@ export type MutationResolvers, ParentType, ContextType>; activateUser?: Resolver, ParentType, ContextType, RequireFields>; addAffiliation?: Resolver, ParentType, ContextType, RequireFields>; - addAnswer?: Resolver, ParentType, ContextType, RequireFields>; + addAnswer?: Resolver, ParentType, ContextType, RequireFields>; addAnswerComment?: Resolver, ParentType, ContextType, RequireFields>; addCustomQuestion?: Resolver>; addCustomSection?: Resolver>; @@ -6938,16 +7091,19 @@ export type PlanSearchResultResolvers, ParentType, ContextType>; title?: Resolver, ParentType, ContextType>; versionedSections?: Resolver>, ParentType, ContextType>; + versionedTemplateId?: Resolver, ParentType, ContextType>; visibility?: Resolver, ParentType, ContextType>; }; export type PlanSectionProgressResolvers = { answeredQuestions?: Resolver; + customSectionId?: Resolver, ParentType, ContextType>; displayOrder?: Resolver; + sectionType?: Resolver; tags?: Resolver>, ParentType, ContextType>; title?: Resolver; totalQuestions?: Resolver; - versionedSectionId?: Resolver; + versionedSectionId?: Resolver, ParentType, ContextType>; }; export type PlanVersionResolvers = { @@ -7111,6 +7267,21 @@ export type ProjectSearchResultsResolvers; }; +export type PublishedQuestionResolvers = { + customQuestionId?: Resolver, ParentType, ContextType>; + guidanceText?: Resolver, ParentType, ContextType>; + hasAnswer?: Resolver, ParentType, ContextType>; + id?: Resolver, ParentType, ContextType>; + json?: Resolver, ParentType, ContextType>; + questionText?: Resolver, ParentType, ContextType>; + questionType?: Resolver, ParentType, ContextType>; + required?: Resolver, ParentType, ContextType>; + requirementText?: Resolver, ParentType, ContextType>; + sampleText?: Resolver, ParentType, ContextType>; + useSampleTextAsDefault?: Resolver, ParentType, ContextType>; + versionedQuestionId?: Resolver, ParentType, ContextType>; +}; + export type PublishedTemplateMetaDataResultsResolvers = { availableAffiliations?: Resolver>>, ParentType, ContextType>; hasBestPracticeTemplates?: Resolver, ParentType, ContextType>; @@ -7136,7 +7307,7 @@ export type QueryResolvers, ParentType, ContextType, RequireFields>; allProjects?: Resolver, ParentType, ContextType, Partial>; answer?: Resolver, ParentType, ContextType, RequireFields>; - answerByVersionedQuestionId?: Resolver, ParentType, ContextType, RequireFields>; + answerByVersionedQuestionId?: Resolver, ParentType, ContextType, RequireFields>; answers?: Resolver>>, ParentType, ContextType, RequireFields>; bestPracticeGuidance?: Resolver, ParentType, ContextType, RequireFields>; bestPracticeSections?: Resolver>>, ParentType, ContextType>; @@ -7181,8 +7352,11 @@ export type QueryResolvers, ParentType, ContextType, RequireFields>; projectMembers?: Resolver>>, ParentType, ContextType, RequireFields>; publishedConditionsForQuestion?: Resolver>>, ParentType, ContextType, RequireFields>; + publishedCustomQuestion?: Resolver, ParentType, ContextType, RequireFields>; + publishedCustomQuestions?: Resolver>>, ParentType, ContextType, RequireFields>; + publishedCustomSection?: Resolver, ParentType, ContextType, RequireFields>; publishedQuestion?: Resolver, ParentType, ContextType, RequireFields>; - publishedQuestions?: Resolver>>, ParentType, ContextType, RequireFields>; + publishedQuestions?: Resolver>>, ParentType, ContextType, RequireFields>; publishedSection?: Resolver, ParentType, ContextType, RequireFields>; publishedSections?: Resolver, ParentType, ContextType, RequireFields>; publishedTemplates?: Resolver, ParentType, ContextType, Partial>; @@ -7805,6 +7979,63 @@ export type UserSearchResultsResolvers; }; +export type VersionedCustomQuestionResolvers = { + created?: Resolver, ParentType, ContextType>; + createdById?: Resolver, ParentType, ContextType>; + customQuestionId?: Resolver; + errors?: Resolver, ParentType, ContextType>; + guidanceText?: Resolver, ParentType, ContextType>; + id?: Resolver, ParentType, ContextType>; + json?: Resolver; + modified?: Resolver, ParentType, ContextType>; + modifiedById?: Resolver, ParentType, ContextType>; + ownerAffiliation?: Resolver, ParentType, ContextType>; + pinnedVersionedQuestionId?: Resolver, ParentType, ContextType>; + pinnedVersionedQuestionType?: Resolver, ParentType, ContextType>; + questionText?: Resolver; + required?: Resolver, ParentType, ContextType>; + requirementText?: Resolver, ParentType, ContextType>; + sampleText?: Resolver, ParentType, ContextType>; + useSampleTextAsDefault?: Resolver, ParentType, ContextType>; + versionedSectionId?: Resolver; + versionedSectionType?: Resolver; + versionedTemplateCustomizationId?: Resolver; +}; + +export type VersionedCustomQuestionErrorsResolvers = { + customQuestionId?: Resolver, ParentType, ContextType>; + general?: Resolver, ParentType, ContextType>; + json?: Resolver, ParentType, ContextType>; + questionText?: Resolver, ParentType, ContextType>; + versionedSectionId?: Resolver, ParentType, ContextType>; + versionedTemplateCustomizationId?: Resolver, ParentType, ContextType>; +}; + +export type VersionedCustomSectionResolvers = { + created?: Resolver, ParentType, ContextType>; + createdById?: Resolver, ParentType, ContextType>; + customSectionId?: Resolver; + errors?: Resolver, ParentType, ContextType>; + guidance?: Resolver, ParentType, ContextType>; + id?: Resolver, ParentType, ContextType>; + introduction?: Resolver, ParentType, ContextType>; + modified?: Resolver, ParentType, ContextType>; + modifiedById?: Resolver, ParentType, ContextType>; + name?: Resolver; + pinnedVersionedSectionId?: Resolver, ParentType, ContextType>; + pinnedVersionedSectionType?: Resolver, ParentType, ContextType>; + questions?: Resolver>, ParentType, ContextType>; + requirements?: Resolver, ParentType, ContextType>; + versionedTemplateCustomizationId?: Resolver; +}; + +export type VersionedCustomSectionErrorsResolvers = { + customSectionId?: Resolver, ParentType, ContextType>; + general?: Resolver, ParentType, ContextType>; + name?: Resolver, ParentType, ContextType>; + versionedTemplateCustomizationId?: Resolver, ParentType, ContextType>; +}; + export type VersionedGuidanceResolvers = { created?: Resolver, ParentType, ContextType>; createdById?: Resolver, ParentType, ContextType>; @@ -7858,6 +8089,10 @@ export type VersionedGuidanceGroupErrorsResolvers = { created?: Resolver, ParentType, ContextType>; createdById?: Resolver, ParentType, ContextType>; + customizationGuidanceText?: Resolver, ParentType, ContextType>; + customizationId?: Resolver, ParentType, ContextType>; + customizationOwnerAffiliation?: Resolver, ParentType, ContextType>; + customizationSampleText?: Resolver, ParentType, ContextType>; displayOrder?: Resolver, ParentType, ContextType>; errors?: Resolver, ParentType, ContextType>; guidanceText?: Resolver, ParentType, ContextType>; @@ -7916,28 +8151,6 @@ export type VersionedQuestionErrorsResolvers, ParentType, ContextType>; }; -export type VersionedQuestionWithFilledResolvers = { - created?: Resolver, ParentType, ContextType>; - createdById?: Resolver, ParentType, ContextType>; - displayOrder?: Resolver, ParentType, ContextType>; - errors?: Resolver, ParentType, ContextType>; - guidanceText?: Resolver, ParentType, ContextType>; - hasAnswer?: Resolver, ParentType, ContextType>; - id?: Resolver, ParentType, ContextType>; - json?: Resolver, ParentType, ContextType>; - modified?: Resolver, ParentType, ContextType>; - modifiedById?: Resolver, ParentType, ContextType>; - questionId?: Resolver; - questionText?: Resolver, ParentType, ContextType>; - required?: Resolver, ParentType, ContextType>; - requirementText?: Resolver, ParentType, ContextType>; - sampleText?: Resolver, ParentType, ContextType>; - useSampleTextAsDefault?: Resolver, ParentType, ContextType>; - versionedQuestionConditions?: Resolver>, ParentType, ContextType>; - versionedSectionId?: Resolver; - versionedTemplateId?: Resolver; -}; - export type VersionedSectionResolvers = { created?: Resolver, ParentType, ContextType>; createdById?: Resolver, ParentType, ContextType>; @@ -8043,6 +8256,7 @@ export type VersionedTemplateSearchResultResolvers, ParentType, ContextType>; templateId?: Resolver, ParentType, ContextType>; version?: Resolver, ParentType, ContextType>; + versionedTemplateCustomizationId?: Resolver, ParentType, ContextType>; visibility?: Resolver, ParentType, ContextType>; }; @@ -8158,6 +8372,7 @@ export type Resolvers = { ProjectSearchResultFunding?: ProjectSearchResultFundingResolvers; ProjectSearchResultMember?: ProjectSearchResultMemberResolvers; ProjectSearchResults?: ProjectSearchResultsResolvers; + PublishedQuestion?: PublishedQuestionResolvers; PublishedTemplateMetaDataResults?: PublishedTemplateMetaDataResultsResolvers; PublishedTemplateSearchResults?: PublishedTemplateSearchResultsResolvers; Query?: QueryResolvers; @@ -8211,6 +8426,10 @@ export type Resolvers = { UserEmailErrors?: UserEmailErrorsResolvers; UserErrors?: UserErrorsResolvers; UserSearchResults?: UserSearchResultsResolvers; + VersionedCustomQuestion?: VersionedCustomQuestionResolvers; + VersionedCustomQuestionErrors?: VersionedCustomQuestionErrorsResolvers; + VersionedCustomSection?: VersionedCustomSectionResolvers; + VersionedCustomSectionErrors?: VersionedCustomSectionErrorsResolvers; VersionedGuidance?: VersionedGuidanceResolvers; VersionedGuidanceErrors?: VersionedGuidanceErrorsResolvers; VersionedGuidanceGroup?: VersionedGuidanceGroupResolvers; @@ -8219,7 +8438,6 @@ export type Resolvers = { VersionedQuestionCondition?: VersionedQuestionConditionResolvers; VersionedQuestionConditionErrors?: VersionedQuestionConditionErrorsResolvers; VersionedQuestionErrors?: VersionedQuestionErrorsResolvers; - VersionedQuestionWithFilled?: VersionedQuestionWithFilledResolvers; VersionedSection?: VersionedSectionResolvers; VersionedSectionErrors?: VersionedSectionErrorsResolvers; VersionedSectionSearchResult?: VersionedSectionSearchResultResolvers;