diff --git a/.gitignore b/.gitignore index 2551bef14..aaa4001d9 100644 --- a/.gitignore +++ b/.gitignore @@ -193,6 +193,7 @@ Temporary Items .idea/**/usage.statistics.xml .idea/**/dictionaries .idea/**/shelf +.idea/prettier.xml # Generated files .idea/**/contentModel.xml diff --git a/data-serving/data-service/api/openapi.yaml b/data-serving/data-service/api/openapi.yaml index 6bd8d5df7..b4dc4b278 100644 --- a/data-serving/data-service/api/openapi.yaml +++ b/data-serving/data-service/api/openapi.yaml @@ -358,7 +358,7 @@ paths: get: summary: Data for cases by country table operationId: listCountryData - tags: [ Suggest ] + tags: [Suggest] responses: '200': $ref: '#/components/responses/200CasesByCountryObject' @@ -368,6 +368,142 @@ paths: $ref: '#/components/responses/403' '500': $ref: '#/components/responses/500' + /cases/bundled: + get: + summary: Lists case bundles + tags: [CaseBundle] + operationId: listCaseBundles + parameters: + - name: page + in: query + description: The pages of cases to skip before starting to collect the result set + required: false + schema: + type: integer + format: int32 + minimum: 1 + default: 1 + - name: limit + in: query + description: The number of items to return + required: false + schema: + type: integer + format: int32 + minimum: 1 + maximum: 100 + default: 10 + - name: count_limit + in: query + description: The maximum number of documents that will be counted in mongoDB to make queries faster + required: false + schema: + type: integer + format: int32 + minimum: 100 + default: 10000 + maximum: 50000 + - name: sort_by + in: query + description: Keyword to sort by + required: false + schema: + type: string + - name: order + in: query + description: Sorting order + required: false + schema: + type: string + - name: verification_status + in: query + description: Verification status of bundled cases + required: false + schema: + type: boolean + - name: q + in: query + description: The search query + required: false + schema: + type: string + examples: + 'full text search': + value: 'this -butnotthis' + summary: Full text search with items that must or must not be present. + keywords: + value: 'curator:foo@bar.com,baz@meh.com country:fr' + summary: > + values are OR'ed for the same keyword and all keywords are AND'ed. + Keyword values can be quoted for multi-words matches and concatenated + with a comma to union them. Bare words are compared for equality, e.g. + country:de. The special character '*' matches any value, e.g. + variant:* returns any case where the variant is not empty. + Supported keywords are: curator, gender, nationality, occupation, + country, outcome, caseid, uploadid, sourceid, sourceurl, verificationstatus, + admin1, admin2, admin3, variant + responses: + '200': + $ref: '#/components/responses/200CaseBundleArray' + '400': + $ref: '#/components/responses/400' + '403': + $ref: '#/components/responses/403' + '422': + $ref: '#/components/responses/422' + '500': + $ref: '#/components/responses/500' + delete: + summary: > + Deletes multiple case bundles. It is required to supply exactly one of either + bundleIds or query in the request body. If bundleIds are supplied, case bundles + corresponding to those bundleIds will be deleted. If query is supplied, + all case bundles that match the query will be deleted. + tags: [CaseBundle] + operationId: deleteCaseBundles + requestBody: + description: Case bundles to delete + required: true + content: + application/json: + schema: + type: object + properties: + bundleIds: + description: Case bundles corresponding to these ids will be deleted + type: array + items: + type: string + query: + description: > + Case bundles matching this query will be deleted. Must contain + non-whitespace characters. + type: string + pattern: \S+ + maxCasesThreshold: + type: number + description: > + An optional safeguard against deletion of too many case bundles. + If you want to delete based on a query for example but fail + if the number of case bundles that's going to be deleted is more + than a given number, set this field to that desired number. + Failure will be indicated by a 422 error status code. + oneOf: + - required: [bundleIds] + - required: [query] + responses: + '204': + description: Case bundles deleted + '400': + $ref: '#/components/responses/400' + '403': + $ref: '#/components/responses/403' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + '500': + $ref: '#/components/responses/500' /cases/symptoms: get: summary: Lists most frequently used symptoms @@ -447,7 +583,7 @@ paths: get: summary: Lists most frequently used location comments operationId: listLocationComments - tags: [ Suggest ] + tags: [Suggest] parameters: - name: limit in: query @@ -540,18 +676,113 @@ paths: $ref: '#/components/responses/404' '500': $ref: '#/components/responses/500' + /cases/bundled/{id}: + parameters: + - name: id + in: path + description: The case bundle ID + required: true + schema: + type: string + get: + summary: Gets a specific case bundle + operationId: getCaseBundle + tags: [CaseBundle] + responses: + '200': + $ref: '#/components/responses/200CaseBundle' + '400': + $ref: '#/components/responses/400' + '403': + $ref: '#/components/responses/403' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + '500': + $ref: '#/components/responses/500' + put: + summary: Updates a specific case bundle + operationId: updateCaseBundle + tags: [CaseBundle] + requestBody: + description: Case bundle to update + required: true + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/Case' + properties: + curator: + $ref: '#/components/schemas/Curator' + required: + - curator + responses: + '200': + $ref: '#/components/responses/200Case' + '400': + $ref: '#/components/responses/400' + '403': + $ref: '#/components/responses/403' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + '500': + $ref: '#/components/responses/500' + delete: + summary: Deletes a specific case bundle + operationId: deleteCaseBundle + tags: [CaseBundle] + responses: + '204': + description: Case bundle deleted + '400': + $ref: '#/components/responses/400' + '403': + $ref: '#/components/responses/403' + '404': + $ref: '#/components/responses/404' + '500': + $ref: '#/components/responses/500' /cases/verify/{id}: post: summary: Verify case -# tags: [ Case ] -# operationId: verifyCase -# requestBody: -# description: Case with verifier -# required: true -# content: -# application/json: -# schema: -# $ref: '#/components/schemas/Verifier' + tags: [ Case ] + operationId: verifyCase + requestBody: + description: Case with verifier + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Verifier' + responses: + '200': + $ref: '#/components/responses/200Case' + '400': + $ref: '#/components/responses/400' + '403': + $ref: '#/components/responses/403' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + '500': + $ref: '#/components/responses/500' + /cases/verify/bundled/{id}: + post: + summary: Verify case bundle + tags: [ CaseBundle ] + operationId: verifyCaseBundle + requestBody: + description: Case bundle with verifier + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Verifier' responses: '200': $ref: '#/components/responses/200Case' @@ -565,6 +796,36 @@ paths: $ref: '#/components/responses/422' '500': $ref: '#/components/responses/500' + /cases/verify/bundled: + post: + summary: > + Verifies multiple case bundles. + tags: [CaseBundle] + operationId: verifyCaseBundles + requestBody: + description: Case bundles to verify + required: true + content: + application/json: + schema: + type: object + properties: + caseBundleIds: + description: Ids of case bundles to verify + type: array + items: + type: string + responses: + '204': + $ref: '#/components/responses/204' + '400': + $ref: '#/components/responses/400' + '403': + $ref: '#/components/responses/403' + '422': + $ref: '#/components/responses/422' + '500': + $ref: '#/components/responses/500' /cases/batchStatusChange: post: summary: Changes status for a list of cases @@ -885,6 +1146,77 @@ components: type: string pathogen: type: string + CaseBundle: + description: A single line-list case. + properties: + _id: + type: object + caseCount: + type: number + minimum: 1 + dateModified: + $ref: '#/components/schemas/Date' + dateCreated: + $ref: '#/components/schemas/Date' + modifiedBy: + type: string + createdBy: + type: string + caseStatus: + description: > + Status of the case + type: string + enum: + - confirmed + - suspected + - discarded + - omit_error + dateEntry: + $ref: '#/components/schemas/Date' + dateReported: + $ref: '#/components/schemas/Date' + countryISO3: + type: string + minLength: 3 + maxLength: 3 + description: ISO 3166-1 alpha-3 code for a country. + example: GBR + country: + type: string + description: name of a country + admin1: + type: string + nullable: true + admin2: + type: string + nullable: true + admin3: + type: string + nullable: true + location: + type: string + nullable: true + description: exact location + ageRange: + type: object + nullable: true + properties: + start: + type: number + end: + type: number + gender: + type: string + nullable: true + enum: [null, male, female, other] + outcome: + type: string + nullable: true + enum: [null, recovered, death] + dateHospitalization: + $ref: '#/components/schemas/Date' + sourceUrl: + type: string CaseArray: type: object properties: @@ -894,6 +1226,15 @@ components: $ref: '#/components/schemas/Case' required: - cases + CaseBundleArray: + type: object + properties: + caseBundles: + type: array + items: + $ref: '#/components/schemas/CaseBundle' + required: + - caseBundles CaseIdsArray: type: object properties: @@ -970,6 +1311,7 @@ components: - type: string - type: object - type: number + nullable: true DateRange: type: object properties: @@ -1148,12 +1490,24 @@ components: application/json: schema: $ref: '#/components/schemas/Case' + '200CaseBundle': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/CaseBundle' '200CaseArray': description: OK content: application/json: schema: $ref: '#/components/schemas/CaseArray' + '200CaseBundleArray': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/CaseBundleArray' '200CaseIdsArray': description: OK content: diff --git a/data-serving/data-service/src/controllers/case.ts b/data-serving/data-service/src/controllers/case.ts index 11e59454b..c3c42e05c 100644 --- a/data-serving/data-service/src/controllers/case.ts +++ b/data-serving/data-service/src/controllers/case.ts @@ -7,7 +7,7 @@ import { } from '../model/day0-case'; import caseFields from '../model/fields.json'; import { CaseStatus, Role } from '../types/enums'; -import { Error, Query } from 'mongoose'; +import { Error, Query, Types } from 'mongoose'; import { ObjectId } from 'mongodb'; import { GeocodeOptions, Geocoder, Resolution } from '../geocoding/geocoder'; import { NextFunction, Request, Response } from 'express'; @@ -57,25 +57,21 @@ const caseFromDTO = async (receivedCase: CaseDTO) => { .map((b) => b._id); } + const geometry = aCase.location?.geometry; + if (!geometry || !geometry.latitude || !geometry.longitude) + delete aCase.location.geometry; + const user = await User.findOne({ email: receivedCase.curator?.email }); if (user) { - logger.info(`User: ${JSON.stringify(user)}`) - if (user.roles.includes(Role.JuniorCurator)) { - aCase.curators = { - createdBy: { - name: user.name || '', - email: user.email - }, - }; - } else if (user.roles.includes(Role.Curator) || user.roles.includes(Role.Admin)) { + logger.info(`User: ${JSON.stringify(user)}`); + if ( + user.roles.includes(Role.JuniorCurator) || + user.roles.includes(Role.Curator) + ) { aCase.curators = { createdBy: { name: user.name || '', - email: user.email - }, - verifiedBy: { - name: user.name || '', - email: user.email + email: user.email, }, }; } @@ -99,7 +95,7 @@ const dtoFromCase = async (storedCase: CaseDocument) => { } if (ageRange) { - if(creator) { + if (creator) { if (verifier) { dto = { ...dto, @@ -131,11 +127,9 @@ const dtoFromCase = async (storedCase: CaseDocument) => { demographics: { ...dto.demographics!, ageRange, - } + }, }; - - // although the type system can't see it, there's an ageBuckets property on the demographics DTO now delete ((dto as unknown) as { demographics: { ageBuckets?: [ObjectId] }; @@ -243,6 +237,30 @@ export class CasesController { res.json(await Promise.all(c.map((aCase) => dtoFromCase(aCase)))); }; + /** + * Get a specific case bundle. + * + * Handles HTTP GET /api/cases/bundled/:id. + */ + getBundled = async (req: Request, res: Response): Promise => { + const c = await Day0Case.find({ + bundleId: req.params.id, + }).lean(); + + if (c.length === 0) { + res.status(404).send({ + message: `Day0Case bundle with ID ${req.params.id} not found.`, + }); + return; + } + + c.forEach((aCase: CaseDocument) => { + delete aCase.caseReference.sourceEntryId; + }); + + res.json(await Promise.all(c.map((aCase) => dtoFromCase(aCase)))); + }; + /** * Streams requested cases to client (curator service). * Doesn't return cases from the restricted collection. @@ -355,11 +373,13 @@ export class CasesController { cast: { date: (value: Date) => { if (value) { - return new Date(value).toISOString().split('T')[0]; + return new Date(value) + .toISOString() + .split('T')[0]; } return value; }, - } + }, }); res.write(stringifiedCase); doc = await cursor.next(); @@ -510,6 +530,125 @@ export class CasesController { } }; + /** + * List all case bundles. + * + * Handles HTTP GET /api/cases/bundled. + */ + listBundled = async (req: Request, res: Response): Promise => { + logger.debug('List method entrypoint'); + const page = Number(req.query.page) || 1; + const limit = Number(req.query.limit) || 10; + const countLimit = Number(req.query.count_limit) || 10000; + const sortBy = String(req.query.sort_by) || 'default'; + const sortByOrder = String(req.query.order) || 'ascending'; + const verificationStatus = + req.query.verification_status !== undefined + ? Boolean(req.query.verification_status) + : undefined; + + logger.debug('Got query params'); + + if (page < 1) { + res.status(422).json({ message: 'page must be > 0' }); + return; + } + if (limit < 1) { + res.status(422).json({ message: 'limit must be > 0' }); + return; + } + // Filter query param looks like &q=some%20search%20query + if ( + typeof req.query.q !== 'string' && + typeof req.query.q !== 'undefined' + ) { + res.status(422).json({ message: 'q must be a unique string' }); + return; + } + + logger.debug('Got past 422s'); + try { + const pipeline = []; + const totalCountPipeline = []; + if (verificationStatus !== undefined) { + pipeline.push({ + $match: { + 'curators.verifiedBy': { $exists: verificationStatus }, + }, + }); + totalCountPipeline.push({ + $match: { + 'curators.verifiedBy': { $exists: verificationStatus }, + }, + }); + } + pipeline.push({ + $group: { + _id: '$bundleId', + caseCount: { $sum: 1 }, + caseIds: { $push: '$_id' }, + verificationStatus: { + $push: { + $cond: { + if: { $ifNull: ['$curators.verifiedBy', false] }, + then: true, + else: false, + }, + }, + }, + dateModified: { + $first: '$revisionMetadata.updateMetadata.date', + }, + dateCreated: { + $first: '$revisionMetadata.creationMetadata.date', + }, + modifiedBy: { + $first: '$revisionMetadata.updateMetadata.curator', + }, + createdBy: { + $first: '$revisionMetadata.creationMetadata.curator', + }, + caseStatus: { $first: '$caseStatus' }, + dateEntry: { $first: '$events.dateEntry' }, + dateReported: { $first: '$events.dateReported' }, + countryISO3: { $first: '$location.countryISO3' }, + country: { $first: '$location.country' }, + admin1: { $first: '$location.admin1' }, + admin2: { $first: '$location.admin2' }, + admin3: { $first: '$location.admin3' }, + location: { $first: '$location.location' }, + ageRange: { $first: '$demographics.ageRange' }, + gender: { $first: '$demographics.gender' }, + outcome: { $first: '$events.outcome' }, + dateHospitalization: { + $first: '$events.dateHospitalization', + }, + dateOnset: { $first: '$events.dateOnset' }, + sourceUrl: { $first: '$caseReference.sourceUrl' }, + }, + }); + totalCountPipeline.push({ $group: { _id: '$bundleId' } }); + const bundledCases = await Day0Case.aggregate(pipeline) + .skip(limit * (page - 1)) + .limit(limit); + const totalCount = (await Day0Case.aggregate(totalCountPipeline)) + .length; + + res.json({ caseBundles: bundledCases, total: totalCount }); + } catch (e) { + if (e instanceof ParsingError) { + logger.error(`Parsing error ${e.message}`); + res.status(422).json({ message: e.message }); + return; + } + logger.error('non-parsing error for query:'); + logger.error(req.query); + if (e instanceof Error) logger.error(e); + res.status(500).json(e); + return; + } + }; + /** * Get table data for Cases by Country. * @@ -524,8 +663,8 @@ export class CasesController { // Get total case cardinality const grandTotalCount = await Day0Case.countDocuments({ caseStatus: { - $nin: [CaseStatus.OmitError, CaseStatus.Discarded] - } + $nin: [CaseStatus.OmitError, CaseStatus.Discarded], + }, }); if (grandTotalCount === 0) { res.status(200).json({}); @@ -537,9 +676,9 @@ export class CasesController { { $match: { caseStatus: { - $nin: [CaseStatus.OmitError, CaseStatus.Discarded] - } - } + $nin: [CaseStatus.OmitError, CaseStatus.Discarded], + }, + }, }, { $group: { @@ -592,9 +731,9 @@ export class CasesController { $ne: null, }, caseStatus: { - $nin: [CaseStatus.OmitError, CaseStatus.Discarded] - } - } + $nin: [CaseStatus.OmitError, CaseStatus.Discarded], + }, + }, }, { $group: { @@ -664,9 +803,9 @@ export class CasesController { { $match: { caseStatus: { - $nin: [CaseStatus.OmitError, CaseStatus.Discarded] - } - } + $nin: [CaseStatus.OmitError, CaseStatus.Discarded], + }, + }, }, { $group: { @@ -698,9 +837,9 @@ export class CasesController { { $match: { caseStatus: { - $nin: [CaseStatus.OmitError, CaseStatus.Discarded] - } - } + $nin: [CaseStatus.OmitError, CaseStatus.Discarded], + }, + }, }, { $match: { @@ -763,8 +902,10 @@ export class CasesController { this.addGeoResolution(req); const currentDate = Date.now(); const curator = req.body.curator.email; + const bundleId = new Types.ObjectId(); const receivedCase = { ...req.body, + bundleId, revisionMetadata: { revisionNumber: 0, creationMetadata: { @@ -803,7 +944,7 @@ export class CasesController { res.status(201).json(result); } catch (e) { const err = e as Error; - if (err.name === 'MongoServerError') { + if (err.name === 'MongoServerError') { logger.error((e as any).errInfo); res.status(422).json({ message: (err as any).errInfo, @@ -848,23 +989,24 @@ export class CasesController { return; } + const verifierEmail = req.body.curator.email; const verifier = await User.findOne({ - email: req.body.curator.email, + email: verifierEmail, }); if (!verifier) { res.status(404).send({ - message: `Verifier with email ${req.body.curator.email} not found.`, + message: `Verifier with email ${verifierEmail} not found.`, }); return; } else { c.set({ curators: { createdBy: c.curators.createdBy, - verifiedBy: verifier._id, + verifiedBy: verifier, }, revisionMetadata: updatedRevisionMetadata( c, - req.body.curator.email, + verifierEmail, 'Case Verification', ), }); @@ -881,6 +1023,108 @@ export class CasesController { } }; + /** + * Verify case bundle. + * + * Handles HTTP POST /api/cases/verify/:id. + */ + verifyBundle = async (req: Request, res: Response): Promise => { + const cs = await Day0Case.find({ + bundleId: req.params.id, + }); + + if (cs.length == 0) { + res.status(404).send({ + message: `Case bundle with ID ${req.params.id} not found.`, + }); + return; + } + + const verifierEmail = req.body.curator.email; + const verifier = await User.findOne({ + email: verifierEmail, + }); + if (!verifier) { + res.status(404).send({ + message: `Verifier with email ${verifierEmail} not found.`, + }); + return; + } else { + const updateData = Date.now(); + await Day0Case.updateMany( + { bundleId: req.params.id }, + { + $set: { + 'curators.verifiedBy': verifier, + 'revisionMetadata.updateMetadata': { + curator: verifierEmail, + note: 'Case Verification', + date: updateData, + }, + }, + $inc: { 'revisionMetadata.revisionNumber': 1 }, + }, + ); + + const responseCases = await Day0Case.find({ + bundleId: req.params.id, + }).lean(); + res.json( + await Promise.all( + responseCases.map((aCase) => dtoFromCase(aCase)), + ), + ); + return; + } + }; + + /** + * Verify case bundle. + * + * Handles HTTP POST /api/cases/verify/bundled/:id. + */ + verifyBundles = async (req: Request, res: Response): Promise => { + const caseBundleIds = req.body.data.caseBundleIds.map( + (caseBundleId: string) => new Types.ObjectId(caseBundleId), + ); + const verifierEmail = req.body.curator.email; + const c = await Day0Case.find({ + bundleId: { $in: caseBundleIds }, + }); + + if (!c) { + res.status(404).send({ + message: `Day0Case with ID ${req.params.id} not found.`, + }); + return; + } + + const verifier = await User.findOne({ email: verifierEmail }); + + if (!verifier) { + res.status(404).send({ + message: `Verifier with email ${verifierEmail} not found.`, + }); + } else { + const updateData = Date.now(); + await Day0Case.updateMany( + { bundleId: { $in: caseBundleIds } }, + { + $set: { + 'curators.verifiedBy': verifier, + 'revisionMetadata.updateMetadata': { + curator: verifierEmail, + note: 'Case Verification', + date: updateData, + }, + }, + $inc: { 'revisionMetadata.revisionNumber': 1 }, + }, + ); + res.status(204).end(); + } + }; + /** * Batch validates cases. */ @@ -1008,6 +1252,7 @@ export class CasesController { // eslint-disable-next-line @typescript-eslint/no-explicit-any const upsertLambda = async (c: any) => { delete c.caseCount; + c.bundleId = new Types.ObjectId(); c = await caseFromDTO(c as CaseDTO); if ( @@ -1104,6 +1349,7 @@ export class CasesController { req.body.curator.email, 'Case Update', ), + bundleId: new Types.ObjectId(), }); await c.save(); @@ -1113,7 +1359,58 @@ export class CasesController { if (err.name === 'ValidationError') { res.status(422).json(err); return; + } else { + res.status(500).json(err); + } + } else { + res.status(500).json(err); + } + return; + } + }; + + /** + * Update a specific case bundle. + * + * Handles HTTP PUT /api/cases/bundled/:id. + */ + updateBundled = async (req: Request, res: Response): Promise => { + try { + const cs = await Day0Case.find({ bundleId: req.params.id }); + + if (!cs) { + res.status(404).send({ + message: `Day0Case bundle with ID ${req.params.id} not found.`, + }); + return; + } + const caseDetails = await caseFromDTO(req.body); + delete caseDetails._id; + delete caseDetails.revisionMetadata; + + for (const c of cs) { + c.set({ + ...caseDetails, + revisionMetadata: updatedRevisionMetadata( + c, + req.body.curator.email, + 'Case Update', + ), + }); + await c.save(); + } + + res.json(await dtoFromCase(cs[0])); + } catch (err) { + logger.error(err as any); + if (err instanceof Error) { + if (err.name === 'ValidationError') { + res.status(422).json(err); + return; + } else { + res.status(500).json(err); } + } else { res.status(500).json(err); } return; @@ -1275,6 +1572,46 @@ export class CasesController { res.status(204).end(); }; + /** + * Deletes multiple case bundles. + * + * Handles HTTP DELETE /api/cases/bundled. + */ + batchDelBundled = async (req: Request, res: Response): Promise => { + if (req.body.bundleIds !== undefined) { + const deleted = await Day0Case.deleteMany({ + bundleId: { $in: req.body.bundleIds }, + }); + if (!deleted) { + res.status(404).send({ + message: `Day0Case with specified bundle IDs not found.`, + }); + return; + } + + res.status(204).end(); + return; + } + + res.status(500).end(); + }; + + /** + * Delete a specific case. + * + * Handles HTTP DELETE /api/cases/:id. + */ + delBundled = async (req: Request, res: Response): Promise => { + const c = await Day0Case.deleteMany({bundleId: req.params.id}); + if (!c) { + res.status(404).send({ + message: `Day0Case bundle with ID ${req.params.id} not found.`, + }); + return; + } + res.status(204).end(); + }; + /** * Geocodes a single location. * @param location The location data. @@ -1474,6 +1811,81 @@ export const casesMatchingSearchQuery = (opts: { return opts.count ? countQuery : casesQuery.lean(); }; +// Returns a mongoose query for all cases matching the given search query. +// If count is true, it returns a query for the number of cases matching +// the search query. +export const bundledCasesMatchingSearchQuery = (opts: { + searchQuery: string; + count: boolean; + limit?: number; + verificationStatus?: boolean; + // Goofy Mongoose types require this. + // eslint-disable-next-line @typescript-eslint/no-explicit-any +}): any => { + // set data limit to 10K by default + const countLimit = opts.limit ? opts.limit : 10000; + const parsedSearch = parseSearchQuery(opts.searchQuery); + let queryOpts; + if (opts.verificationStatus) { + queryOpts = parsedSearch.fullTextSearch + ? { + $text: { $search: parsedSearch.fullTextSearch }, + 'curators.verifiedBy': { $exists: opts.verificationStatus }, + } + : { 'curators.verifiedBy': { $exists: opts.verificationStatus } }; + } else { + queryOpts = parsedSearch.fullTextSearch + ? { + $text: { $search: parsedSearch.fullTextSearch }, + } + : {}; + } + + // Always search with case-insensitivity. + const casesQuery: Query = Day0Case.find( + queryOpts, + ); + + const countQuery: Query = Day0Case.countDocuments( + queryOpts, + ).limit(countLimit); + + // Fill in keyword filters. + parsedSearch.filters.forEach((f) => { + if (f.values.length == 1) { + const searchTerm = f.values[0]; + if (searchTerm === '*') { + casesQuery.where(f.path).exists(true); + countQuery.where(f.path).exists(true); + } else if (f.dateOperator) { + casesQuery.where({ + [f.path]: { + [f.dateOperator]: f.values[0], + }, + }); + countQuery.where({ + [f.path]: { + [f.dateOperator]: f.values[0], + }, + }); + } else if ( + f.path === 'demographics.gender' && + f.values[0] === 'notProvided' + ) { + casesQuery.where(f.path).exists(false); + countQuery.where(f.path).exists(false); + } else { + casesQuery.where(f.path).equals(f.values[0]); + countQuery.where(f.path).equals(f.values[0]); + } + } else { + casesQuery.where(f.path).in(f.values); + countQuery.where(f.path).in(f.values); + } + }); + return opts.count ? countQuery : casesQuery.lean(); +}; + /** * Find IDs of existing cases that have {caseReference.sourceId, * caseReference.sourceEntryId} combinations matching any cases in the provided diff --git a/data-serving/data-service/src/controllers/preprocessor.ts b/data-serving/data-service/src/controllers/preprocessor.ts index 14478502e..9c1fcad59 100644 --- a/data-serving/data-service/src/controllers/preprocessor.ts +++ b/data-serving/data-service/src/controllers/preprocessor.ts @@ -40,6 +40,20 @@ export const getCase = async ( return null; }; +export const getCasesForBundle = async ( + request: Request, +): Promise => { + if ( + (request.method == 'PUT' || request.method == 'DELETE') && + request.params?.id + ) { + // Update or delete. + return Day0Case.find({ bundleId: request.params.id }); + } + + return null; +}; + // Remove cases from the request that don't need to be updated. export const batchUpsertDropUnchangedCases = async ( request: Request, @@ -190,6 +204,24 @@ export const createCaseRevision = async ( next(); }; +export const createCaseRevisionForBundle = async ( + request: Request, + response: Response, + next: NextFunction, +): Promise => { + const cs = await getCasesForBundle(request); + + if (cs) { + for (const c of cs) { + await new CaseRevision({ + case: c, + }).save(); + } + } + + next(); +}; + export const batchDeleteCheckThreshold = async ( request: Request, response: Response, @@ -233,6 +265,18 @@ export const createBatchDeleteCaseRevisions = async ( case: c, }; }); + } else if (request.body.bundleIds !== undefined) { + casesToDelete = ( + await Day0Case.find({ + bundleId: { + $in: request.body.bundleIds, + }, + }).exec() + ).map((c) => { + return { + case: c, + }; + }); } else { const casesQuery = casesMatchingSearchQuery({ searchQuery: request.body.query, diff --git a/data-serving/data-service/src/index.ts b/data-serving/data-service/src/index.ts index ce4a8b9fa..92e4c509c 100644 --- a/data-serving/data-service/src/index.ts +++ b/data-serving/data-service/src/index.ts @@ -9,6 +9,7 @@ import { createBatchUpdateCaseRevisions, createBatchUpsertCaseRevisions, createCaseRevision, + createCaseRevisionForBundle, findCasesToUpdate, setBatchUpsertFields, } from './controllers/preprocessor'; @@ -119,11 +120,13 @@ apiRouter.get('/cases/symptoms', cases.listSymptoms); apiRouter.get('/cases/placesOfTransmission', cases.listPlacesOfTransmission); apiRouter.get('/cases/occupations', cases.listOccupations); apiRouter.get('/cases/locationComments', cases.listLocationComments); + apiRouter.post( '/cases/verify/:id(\\d+$)', createCaseRevision, caseController.verify, ); + apiRouter.get('/cases/:id(\\d+$)', caseController.get); apiRouter.post('/cases', caseController.create); apiRouter.post('/cases/download', caseController.download); @@ -155,6 +158,34 @@ apiRouter.delete( caseController.batchDel, ); apiRouter.delete('/cases/:id(\\d+$)', createCaseRevision, caseController.del); +// BUNDLED CASES +apiRouter.get('/cases/bundled', caseController.listBundled); +apiRouter.get('/cases/bundled/:id([a-z0-9]{24})', caseController.getBundled); +apiRouter.put( + '/cases/bundled/:id([a-z0-9]{24})', + createCaseRevisionForBundle, + caseController.updateBundled, +); +apiRouter.delete( + '/cases/bundled', + createBatchDeleteCaseRevisions, + caseController.batchDelBundled, +); +apiRouter.delete( + '/cases/bundled/:id([a-z0-9]{24})', + createCaseRevisionForBundle, + caseController.delBundled, +); +apiRouter.post( + '/cases/verify/bundled', + createCaseRevision, + caseController.verifyBundles, +); +apiRouter.post( + '/cases/verify/bundled/:id([a-z0-9]{24})', + createCaseRevision, + caseController.verifyBundle, +); app.use('/api', apiRouter); diff --git a/data-serving/data-service/src/model/day0-case.ts b/data-serving/data-service/src/model/day0-case.ts index 122cc0d9f..af390f0de 100644 --- a/data-serving/data-service/src/model/day0-case.ts +++ b/data-serving/data-service/src/model/day0-case.ts @@ -81,6 +81,7 @@ export type CuratorsDocument = mongoose.Document & { export const caseSchema = new mongoose.Schema( { _id: Number, + bundleId: mongoose.Types.ObjectId, caseStatus: { type: String, enum: CaseStatus, @@ -188,6 +189,7 @@ export interface ISource { export type ICase = { caseStatus: CaseStatus; + bundleId: mongoose.Types.ObjectId; comment?: string; pathogen: string; symptoms: string; diff --git a/data-serving/scripts/setup-db/schemas/day0cases.indexes.json b/data-serving/scripts/setup-db/schemas/day0cases.indexes.json index b55dd708a..dc4a62d71 100644 --- a/data-serving/scripts/setup-db/schemas/day0cases.indexes.json +++ b/data-serving/scripts/setup-db/schemas/day0cases.indexes.json @@ -46,6 +46,16 @@ } } }, + { + "name": "bundleIdIdx", + "key": { + "bundleId": -1 + }, + "collation": { + "locale": "en_US", + "strength": 2 + } + }, { "name": "demographicsGenderIdx", "key": { diff --git a/data-serving/scripts/setup-db/schemas/day0cases.schema.json b/data-serving/scripts/setup-db/schemas/day0cases.schema.json index 4175de3de..7b9d5d1bc 100644 --- a/data-serving/scripts/setup-db/schemas/day0cases.schema.json +++ b/data-serving/scripts/setup-db/schemas/day0cases.schema.json @@ -11,6 +11,9 @@ "__v": { "bsonType": "int" }, + "bundleId": { + "bsonType": "objectId" + }, "list": { "bsonType": "bool" }, @@ -253,14 +256,10 @@ "additionalProperties": false, "properties": { "latitude": { - "bsonType": "number", - "minimum": -90, - "maximum": 90 + "bsonType": "number" }, "longitude": { - "bsonType": "number", - "minimum": -180, - "maximum": 180 + "bsonType": "number" } } }, diff --git a/geocoding/location-service/Dockerfile b/geocoding/location-service/Dockerfile index 7b156c704..8d679e0be 100644 --- a/geocoding/location-service/Dockerfile +++ b/geocoding/location-service/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.10-slim +FROM python:3.10-bullseye RUN apt-get update -y RUN apt-get install -y python3-pip diff --git a/verification/curator-service/api/openapi/openapi.yaml b/verification/curator-service/api/openapi/openapi.yaml index 062408b0f..26c7f20a7 100644 --- a/verification/curator-service/api/openapi/openapi.yaml +++ b/verification/curator-service/api/openapi/openapi.yaml @@ -633,20 +633,135 @@ paths: $ref: '#/components/responses/200FullDataSetDownload' '500': $ref: '#/components/responses/500' + /cases/bundled: + get: + tags: [CaseBundle] + summary: Lists bundled cases + operationId: listBundledCases + parameters: + - name: page + in: query + description: The pages of sources to skip before starting to collect the result set + required: false + schema: + type: integer + format: int32 + minimum: 1 + default: 1 + - name: limit + in: query + description: The number of cases to return + required: false + schema: + type: integer + format: int32 + minimum: 1 + maximum: 100 + default: 10 + - name: count_limit + in: query + description: The maximum number of documents that will be counted in mongoDB to make queries faster + required: false + schema: + type: integer + format: int32 + minimum: 100 + default: 10000 + maximum: 50000 + - name: sort_by + in: query + description: Keyword to sort by + required: false + schema: + type: string + - name: order + in: query + description: Sorting order + required: false + schema: + type: string + - name: verification_status + in: query + description: Verification status of bundled cases + required: false + schema: + type: boolean + - name: q + in: query + description: The search query + required: false + allowEmptyValue: true + allowReserved: true + schema: + type: string + responses: + '200': + $ref: '#/components/responses/200CaseBundleArray' + '400': + $ref: '#/components/responses/400' + '403': + $ref: '#/components/responses/403' + '422': + $ref: '#/components/responses/422' + '500': + $ref: '#/components/responses/500' + delete: + summary: > + Deletes multiple case bundles. It is required to supply exactly one of either + bundleIds or query in the request body. If bundleIds are supplied, case bundles + corresponding to those caseIds will be deleted. If query is supplied, + all case bundles that match the query will be deleted. + tags: [CaseBundle] + operationId: deleteCaseBundles + requestBody: + description: Case bundles to delete + required: true + content: + application/json: + schema: + type: object + properties: + bundleIds: + description: Cases corresponding to these ids will be deleted + type: array + items: + type: string + query: + description: > + Case bundles matching this query will be deleted. Must contain + non-whitespace characters. + type: string + pattern: \S+ + oneOf: + - required: [bundleIds] + - required: [query] + responses: + '204': + description: Case bundles deleted + '400': + $ref: '#/components/responses/400' + '403': + $ref: '#/components/responses/403' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + '500': + $ref: '#/components/responses/500' /cases/countryData: - get: - summary: Data for cases by country table - operationId: listCountryData - tags: [ Suggest ] - responses: - '200': - $ref: '#/components/responses/200CasesByCountryObject' - '400': - $ref: '#/components/responses/400' - '403': - $ref: '#/components/responses/403' - '500': - $ref: '#/components/responses/500' + get: + summary: Data for cases by country table + operationId: listCountryData + tags: [Suggest] + responses: + '200': + $ref: '#/components/responses/200CasesByCountryObject' + '400': + $ref: '#/components/responses/400' + '403': + $ref: '#/components/responses/403' + '500': + $ref: '#/components/responses/500' /cases/symptoms: get: summary: Lists most frequently used sypmtoms @@ -725,7 +840,7 @@ paths: /cases/locationComments: get: summary: Lists most frequently used location comments - tags: [ Suggest ] + tags: [Suggest] operationId: listLocationComments parameters: - name: limit @@ -934,18 +1049,42 @@ paths: $ref: '#/components/responses/404' '500': $ref: '#/components/responses/500' - /cases/verify/{id}: - post: - tags: [ Case ] -# summary: Creates one (or multiple identical) new cases -# operationId: verifyCase -# requestBody: -# description: Case with verifier -# required: true -# content: -# application/json: -# schema: -# $ref: '#/components/schemas/Verifier' + /cases/bundled/{id}: + parameters: + - name: id + in: path + description: The case bundle ID + required: true + schema: + type: string + get: + summary: Gets a specific case bundle + operationId: getCaseBundle + tags: [CaseBundle] + responses: + '200': + $ref: '#/components/responses/200CaseBundle' + '400': + $ref: '#/components/responses/400' + '403': + $ref: '#/components/responses/403' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + '500': + $ref: '#/components/responses/500' + put: + tags: [CaseBundle] + summary: Updates a specific case bundle + operationId: updateCaseBundle + requestBody: + description: Case bundle to update + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Case' responses: '200': $ref: '#/components/responses/200Case' @@ -953,10 +1092,103 @@ paths: $ref: '#/components/responses/400' '403': $ref: '#/components/responses/403' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + '500': + $ref: '#/components/responses/500' + delete: + tags: [CaseBundle] + summary: Deletes a specific case bundle + operationId: deleteCaseBundle + responses: + '204': + description: Case bundle deleted + '400': + $ref: '#/components/responses/400' + '403': + $ref: '#/components/responses/403' + '404': + $ref: '#/components/responses/404' + '500': + $ref: '#/components/responses/500' + /cases/verify/{id}: + post: + tags: [ Case ] + summary: Verifies case + operationId: verifyCase + requestBody: + description: Case with verifier + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Verifier' + responses: + '200': + $ref: '#/components/responses/200Case' + '400': + $ref: '#/components/responses/400' + '403': + $ref: '#/components/responses/403' + '422': + $ref: '#/components/responses/422' + '500': + $ref: '#/components/responses/500' + /cases/verify/bundled: + post: + summary: > + Verifies multiple case bundles. + tags: [Case] + operationId: verifyBundled + requestBody: + description: Case bundles to verify + required: true + content: + application/json: + schema: + type: object + properties: + caseBundleIds: + description: Ids of case bundles to verify + type: array + items: + type: string + responses: + '204': + $ref: '#/components/responses/204' + '400': + $ref: '#/components/responses/400' + '403': + $ref: '#/components/responses/403' '422': $ref: '#/components/responses/422' '500': $ref: '#/components/responses/500' + /cases/verify/bundled/{id}: + post: + tags: [ CaseBundle ] + summary: Verifies cases in a specific case bundle + operationId: verifyCaseBundle + requestBody: + description: Case bundle with verifier + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Verifier' + responses: + '200': + $ref: '#/components/responses/200Case' + '400': + $ref: '#/components/responses/400' + '403': + $ref: '#/components/responses/403' + '422': + $ref: '#/components/responses/422' + '500': + $ref: '#/components/responses/500' /geocode/seed: post: tags: [Geocode] @@ -1053,7 +1285,7 @@ paths: $ref: '#/components/responses/500' /geocode/admin1: get: - tags: [ Suggest ] + tags: [Suggest] summary: Suggest list of Admin1 locations for ISO-3166-1 3-letter country code. operationId: countryName parameters: @@ -1078,7 +1310,7 @@ paths: /geocode/admin2: get: - tags: [ Suggest ] + tags: [Suggest] summary: Suggest list of Admin2 locations for Admin1 location. operationId: countryName parameters: @@ -1103,7 +1335,7 @@ paths: /geocode/admin3: get: - tags: [ Suggest ] + tags: [Suggest] summary: Suggest list of Admin3 locations for Admin2 location. operationId: countryName parameters: @@ -1519,11 +1751,11 @@ components: items: $ref: '#/components/schemas/AcknowledgmentSource' Verifier: - description: Verifier data. - properties: - email: - description: email address of verifier - type: string + description: Verifier data. + properties: + email: + description: email address of verifier + type: string Case: description: A single line-list case. properties: @@ -1717,6 +1949,79 @@ components: $ref: '#/components/schemas/Date' vaccineSideEffects: type: string + CaseBundle: + description: A single line-list case. + properties: + _id: + oneOf: + - type: string + - type: object + caseCount: + type: number + minimum: 1 + dateModified: + $ref: '#/components/schemas/Date' + dateCreated: + $ref: '#/components/schemas/Date' + modifiedBy: + type: string + createdBy: + type: string + caseStatus: + description: > + Status of the case + type: string + enum: + - confirmed + - suspected + - discarded + - omit_error + dateEntry: + $ref: '#/components/schemas/Date' + dateReported: + $ref: '#/components/schemas/Date' + countryISO3: + type: string + minLength: 3 + maxLength: 3 + description: ISO 3166-1 alpha-3 code for a country. + example: GBR + country: + type: string + description: name of a country + admin1: + type: string + nullable: true + admin2: + type: string + nullable: true + admin3: + type: string + nullable: true + location: + type: string + nullable: true + description: exact location + ageRange: + type: object + nullable: true + properties: + start: + type: number + end: + type: number + gender: + type: string + nullable: true + enum: [null, male, female, other] + outcome: + type: string + nullable: true + enum: [null, recovered, death] + dateHospitalization: + $ref: '#/components/schemas/Date' + sourceUrl: + type: string CaseStatus: type: string enum: [confirmed, suspected, discarded, omit_error] @@ -1729,6 +2034,15 @@ components: $ref: '#/components/schemas/Case' required: - cases + CaseBundleArray: + type: object + properties: + caseBundles: + type: array + items: + $ref: '#/components/schemas/CaseBundle' + required: + - caseBundles CaseIdsArray: type: object properties: @@ -1807,6 +2121,7 @@ components: - type: string - type: object - type: number + nullable: true example: '02/20/2020' DateRange: type: object @@ -1910,7 +2225,7 @@ components: enum: - admin - curator - - "junior curator" + - 'junior curator' RoleArray: type: object properties: @@ -1934,17 +2249,17 @@ components: type: string description: Name of a country admin1: - type: string + type: string admin1WikiID: - type: string + type: string admin2: - type: string + type: string admin2WikiId: - type: string + type: string admin3: type: string admin3WikiId: - type: string + type: string location: type: string description: Exact location @@ -1990,26 +2305,26 @@ components: description: Third administrative subdivision of a country example: Any district in Costa Rica admin1: - type: string - description: Name of the area in admin1 resolution - example: Lodz Voivodeship + type: string + description: Name of the area in admin1 resolution + example: Lodz Voivodeship admin1WikiId: - type: string - description: Wiki identifier of the area in admin1 resolution + type: string + description: Wiki identifier of the area in admin1 resolution admin2: - type: string - description: Name of the area in admin2 resolution - example: Lodz County + type: string + description: Name of the area in admin2 resolution + example: Lodz County admin2WikiId: - type: string - description: Wiki identifier of the area in admin2 resolution + type: string + description: Wiki identifier of the area in admin2 resolution admin3: type: string description: Name of the area in admin3 resolution example: Lodz admin3WikiId: - type: string - description: Wiki identifier of the area in admin3 resolution + type: string + description: Wiki identifier of the area in admin3 resolution name: type: string description: > @@ -2160,6 +2475,18 @@ components: application/json: schema: $ref: '#/components/schemas/CaseArray' + '200CaseBundle': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/CaseBundle' + '200CaseBundleArray': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/CaseBundleArray' '200CaseIdsArray': description: OK content: diff --git a/verification/curator-service/api/src/controllers/cases.ts b/verification/curator-service/api/src/controllers/cases.ts index 442ded43d..dd6f8a8d5 100644 --- a/verification/curator-service/api/src/controllers/cases.ts +++ b/verification/curator-service/api/src/controllers/cases.ts @@ -61,6 +61,40 @@ export default class CasesController { } }; + /** List bundled simply forwards the request to the data service */ + listBundled = async (req: Request, res: Response): Promise => { + let query; + if (req.url === defaultInputQuery) { + query = defaultOutputQuery; + } else { + query = req.url; + logger.info(`Applying filter: ${query}`); + } + try { + const response = await axios.get( + this.dataServerURL + '/api' + query, + ); + if (response.status >= 400) { + logger.error( + `A server error occurred when trying to list bundled data using URL: ${query}. Response status code: ${response.status}`, + ); + } + res.status(response.status).json(response.data); + } catch (e) { + const err = e as AxiosError; + + logger.error( + `Exception thrown by axios accessing URL: ${query}`, + err, + ); + if (err.response?.status && err.response?.data) { + res.status(err.response.status).send(err.response.data); + return; + } + res.status(500).send(err); + } + }; + private logOutcomeOfAppendingDownloadToUser( userId: string, result: ModifyResult, @@ -439,6 +473,24 @@ export default class CasesController { } }; + /** getBundled simply forwards the request to the data service */ + getBundled = async (req: Request, res: Response): Promise => { + try { + const response = await axios.get( + this.dataServerURL + '/api' + req.url, + ); + res.status(response.status).json(response.data); + } catch (e) { + const err = e as AxiosError; + logger.error(err); + if (err.response?.status && err.response?.data) { + res.status(err.response.status).send(err.response.data); + return; + } + res.status(500).send(err); + } + }; + /** batchDel simply forwards the request to the data service */ batchDel = async (req: Request, res: Response): Promise => { try { @@ -481,6 +533,48 @@ export default class CasesController { } }; + /** del simply forwards the request to the data service */ + batchDelBundled = async (req: Request, res: Response): Promise => { + try { + // Limit number of deletes a non-admin can do. + // Cf. https://github.com/globaldothealth/list/issues/937. + if (!(req.user as IUser)?.roles?.includes('admin')) { + req.body['maxCasesThreshold'] = 10000; + } + const response = await axios.delete( + this.dataServerURL + '/api' + req.url, + { data: req.body }, + ); + res.status(response.status).end(); + } catch (e) { + const err = e as AxiosError; + logger.error(err); + if (err.response?.status && err.response?.data) { + res.status(err.response.status).send(err.response.data); + return; + } + res.status(500).send(err); + } + }; + + /** del simply forwards the request to the data service */ + delBundled = async (req: Request, res: Response): Promise => { + try { + const response = await axios.delete( + this.dataServerURL + '/api' + req.url, + ); + res.status(response.status).end(); + } catch (e) { + const err = e as AxiosError; + logger.error(err); + if (err.response?.status && err.response?.data) { + res.status(err.response.status).send(err.response.data); + return; + } + res.status(500).send(err); + } + }; + /** update simply forwards the request to the data service */ update = async (req: Request, res: Response): Promise => { try { @@ -503,6 +597,29 @@ export default class CasesController { } }; + /** update simply forwards the request to the data service */ + updateBundled = async (req: Request, res: Response): Promise => { + logger.error('updateBundled'); + try { + const response = await axios.put( + this.dataServerURL + '/api' + req.url, + { + ...req.body, + curator: { email: (req.user as IUser).email }, + }, + ); + res.status(response.status).json(response.data); + } catch (e) { + const err = e as AxiosError; + logger.error(err); + if (err.response?.status && err.response?.data) { + res.status(err.response.status).send(err.response.data); + return; + } + res.status(500).send(err); + } + }; + /** * upsert forwards the request to the data service. */ @@ -687,6 +804,57 @@ export default class CasesController { } }; + /** + * verify forwards the query to the data service. + * It does set the curator in the request to the data service based on the + * currently logged-in user. + */ + verifyBundle = async (req: Request, res: Response): Promise => { + try { + const response = await axios.post( + this.dataServerURL + '/api' + req.url, + { + ...req.body, + curator: { email: (req.user as IUser).email }, + }, + ); + res.status(response.status).json(response.data); + } catch (e) { + const err = e as AxiosError; + if (err.response?.status && err.response?.data) { + res.status(err.response.status).send(err.response.data); + return; + } + res.status(500).send(err); + } + }; + + /** + * verify bundled forwards the query to the data service. + * It does set the curator in the request to the data service based on the + * currently logged-in user. + */ + verifyBundles = async (req: Request, res: Response): Promise => { + try { + const response = await axios.post( + this.dataServerURL + '/api' + req.url, + { + ...req.body, + curator: { email: (req.user as IUser).email }, + }, + ); + + res.status(response.status).end(); + } catch (e) { + const err = e as AxiosError; + if (err.response?.status && err.response?.data) { + res.status(err.response.status).send(err.response.data); + return; + } + res.status(500).send(err); + } + }; + /** * batchStatusChange forwards the query to the data service. * It does set the curator in the request to the data service based on the diff --git a/verification/curator-service/api/src/index.ts b/verification/curator-service/api/src/index.ts index 25f54eaee..a6c415bd6 100644 --- a/verification/curator-service/api/src/index.ts +++ b/verification/curator-service/api/src/index.ts @@ -302,7 +302,7 @@ async function makeApp() { ); apiRouter.post( '/cases/verify/:id(\\d+$)', - mustHaveAnyRole([Role.Curator, Role.JuniorCurator]), + mustHaveAnyRole([Role.Curator]), casesController.verify, ); apiRouter.post( @@ -364,7 +364,48 @@ async function makeApp() { mustHaveAnyRole([Role.Curator]), casesController.del, ); - + // BUNDLED CASES + apiRouter.get( + '/cases/bundled', + authenticateByAPIKey, + mustBeAuthenticated, + casesController.listBundled, + ); + apiRouter.get( + '/cases/bundled/:id([a-z0-9]{24})', + authenticateByAPIKey, + mustBeAuthenticated, + casesController.getBundled, + ); + apiRouter.put( + '/cases/bundled/:id([a-z0-9]{24})', + authenticateByAPIKey, + mustHaveAnyRole([Role.Curator, Role.JuniorCurator]), + casesController.updateBundled, + ); + apiRouter.post( + '/cases/verify/bundled', + mustHaveAnyRole([Role.Curator]), + casesController.verifyBundles, + ); + apiRouter.post( + '/cases/verify/bundled/:id([a-z0-9]{24})', + authenticateByAPIKey, + mustHaveAnyRole([Role.Curator]), + casesController.verifyBundle, + ); + apiRouter.delete( + '/cases/bundled', + authenticateByAPIKey, + mustHaveAnyRole([Role.Curator]), + casesController.batchDelBundled, + ); + apiRouter.delete( + '/cases/bundled/:id([a-z0-9]{24})', + authenticateByAPIKey, + mustHaveAnyRole([Role.Curator]), + casesController.delBundled, + ); // Configure users controller. apiRouter.get( '/users', diff --git a/verification/curator-service/ui/cypress/e2e/components/BulkVerification.ts b/verification/curator-service/ui/cypress/e2e/components/BulkVerification.ts new file mode 100644 index 000000000..c166afa9e --- /dev/null +++ b/verification/curator-service/ui/cypress/e2e/components/BulkVerification.ts @@ -0,0 +1,61 @@ +import { CaseStatus } from '../../support/commands'; +import {Role} from "../../../src/api/models/User"; + +/* eslint-disable no-undef */ +describe('Linelist table', function () { + beforeEach(() => { + cy.task('clearCasesDB', {}); + cy.intercept('GET', '/auth/profile').as('getProfile'); + cy.intercept('GET', '/api/cases*').as('getCases'); + }); + + afterEach(() => { + cy.clearSeededLocations(); + }); + + it('Displays and verifies bundled cases correctly', function () { + cy.login({roles: [ Role.JuniorCurator ]}) + cy.addCase({ + country: 'France', + countryISO3: 'FRA', + dateEntry: '2020-05-01', + dateReported: '2020-05-01', + sourceUrl: 'www.example.com', + caseStatus: CaseStatus.Confirmed, + }); + cy.logout(); + cy.login({roles: [ Role.Curator ]}) + + // Make sure that case is not verified + cy.visit('/'); + cy.wait('@getProfile'); + cy.wait('@getCases'); + cy.get('[data-testid="CheckCircleOutlineIcon"]').should('not.exist'); + + // Verify case + cy.visit('/bulk-verification'); + + // We don't need additional library for just one test, we can format date manually + const today = new Date(); + const year = today.getFullYear(); + const month = today.getMonth() < 9 ? `0${today.getMonth() + 1}` : today.getMonth() + 1; + const day = today.getDate() < 10 ? `0${today.getDate()}` : today.getDate(); + cy.contains(`${year}-${month}-${day}`) + cy.contains('superuser@test.com'); + cy.contains('France'); + cy.contains('www.example.com'); + cy.contains('2020-05-01'); + cy.contains('confirmed'); + cy.get('tr').get('input[type="checkbox"]').check(); + cy.get('[data-testid="verify-case-bundles-button"]').click(); + cy.get('[data-testid="confirm-case-bundles-verification-button"]').click(); + + // Case bundle no longer shows in bulk verification list + cy.contains('No records to display').should('exist'); + + // Case from case bundle is now verified + cy.visit('/'); + cy.wait('@getCases'); + cy.get('[data-testid="CheckCircleOutlineIcon"]').should('exist'); + }); +}); diff --git a/verification/curator-service/ui/src/components/App/index.tsx b/verification/curator-service/ui/src/components/App/index.tsx index 44c0e58c4..f7feac47d 100644 --- a/verification/curator-service/ui/src/components/App/index.tsx +++ b/verification/curator-service/ui/src/components/App/index.tsx @@ -14,6 +14,7 @@ import { import { DownloadButton } from '../DownloadButton'; import LinelistTable from '../LinelistTable'; import PivotTables from '../PivotTables'; +import BundleOperations from '../BundleOperations'; import { Link, Route, @@ -32,6 +33,7 @@ import BulkCaseForm from '../BulkCaseForm'; import CaseForm from '../CaseForm'; import AcknowledgmentsPage from '../AcknowledgmentsPage'; import EditCase from '../EditCase'; +import EditCaseBundle from '../EditCaseBundle'; import GHListLogo from '../GHListLogo'; import LandingPage from '../landing-page/LandingPage'; import MenuIcon from '@mui/icons-material/Menu'; @@ -41,6 +43,7 @@ import SourceTable from '../SourceTable'; import TermsOfUse from '../TermsOfUse'; import UploadsTable from '../UploadsTable'; import Users from '../Users'; +import ViewBundle from '../ViewBundle'; import ViewCase from '../ViewCase'; import clsx from 'clsx'; import { useCookieBanner } from '../../hooks/useCookieBanner'; @@ -456,6 +459,9 @@ export default function App(): JSX.Element { {user && ( } /> )} + {hasAnyRole(user, [Role.Curator]) && ( + } /> + )} {hasAnyRole(user, [Role.Curator, Role.JuniorCurator]) && ( } /> )} @@ -531,6 +537,21 @@ export default function App(): JSX.Element { } /> )} + {user && + hasAnyRole(user, [ + Role.Curator, + Role.JuniorCurator, + ]) && ( + + } + /> + )} {user && ( )} + {user && ( + + } + /> + )} } diff --git a/verification/curator-service/ui/src/components/BundleOperations/EnhancedTableToolbar.tsx b/verification/curator-service/ui/src/components/BundleOperations/EnhancedTableToolbar.tsx new file mode 100644 index 000000000..2f9dd20c6 --- /dev/null +++ b/verification/curator-service/ui/src/components/BundleOperations/EnhancedTableToolbar.tsx @@ -0,0 +1,137 @@ +import { useEffect, useState } from 'react'; +import { + CheckCircleOutline as VerifyIcon, + DeleteOutline as DeleteIcon, +} from '@mui/icons-material'; +import { Button, Stack, Toolbar, Tooltip, Typography } from '@mui/material'; + +import { useAppDispatch, useAppSelector } from '../../hooks/redux'; +import { + setDeleteCasesDialogOpen, + setVerifyCasesDialogOpen, + setRowsAcrossPagesSelected, + setCasesSelected, +} from '../../redux/bundledCases/slice'; +import { + selectCasesSelected, + selectCases, + selectSearchQuery, + selectTotalCases, + selectRowsAcrossPages, +} from '../../redux/bundledCases/selectors'; + +import Header from './Header'; + +const EnhancedTableToolbar = () => { + const dispatch = useAppDispatch(); + + const selectedCases = useAppSelector(selectCasesSelected); + const cases = useAppSelector(selectCases); + const searchQuery = useAppSelector(selectSearchQuery); + const totalCases = useAppSelector(selectTotalCases); + const rowsAcrossPagesSelected = useAppSelector(selectRowsAcrossPages); + + const [numSelectedCases, setNumSelectedCases] = useState( + selectedCases.length, + ); + + useEffect(() => { + setNumSelectedCases(selectedCases.length); + }, [selectedCases]); + + const handleSelectAllRowsAcrossPagesClick = () => { + if (rowsAcrossPagesSelected || numSelectedCases === totalCases) { + dispatch(setRowsAcrossPagesSelected(false)); + dispatch(setCasesSelected([])); + setNumSelectedCases(0); + } else { + dispatch(setRowsAcrossPagesSelected(cases.length < totalCases)); + dispatch( + setCasesSelected( + // eslint-disable-next-line + cases.map((caseObj) => caseObj.caseReference.id!), + ), + ); + setNumSelectedCases(totalCases); + } + }; + + return ( + 0 ? 2 : 0 }, + pr: { xs: 1, sm: 1 }, + ...(numSelectedCases > 0 && { + bgcolor: (theme) => + theme.custom.palette.appBar.backgroundColor, + }), + }} + > + {numSelectedCases > 0 ? ( + <> + + + {rowsAcrossPagesSelected + ? totalCases + : numSelectedCases}{' '} + row + {numSelectedCases > 1 ? 's' : ''} selected + + + {searchQuery && searchQuery !== '' && ( + + )} + + + + + + + + + + + + ) : ( +
+ )} + + ); +}; + +export default EnhancedTableToolbar; diff --git a/verification/curator-service/ui/src/components/BundleOperations/FilterChips.tsx b/verification/curator-service/ui/src/components/BundleOperations/FilterChips.tsx new file mode 100644 index 000000000..74acb4c1e --- /dev/null +++ b/verification/curator-service/ui/src/components/BundleOperations/FilterChips.tsx @@ -0,0 +1,55 @@ +import { useLocation, useNavigate } from 'react-router-dom'; +import { useAppSelector, useAppDispatch } from '../../hooks/redux'; +import { selectFilterBreadcrumbs } from '../../redux/app/selectors'; +import { deleteFilterBreadcrumbs } from '../../redux/app/slice'; +import { ChipData } from '../App'; +import { setModalOpen, setActiveFilterInput } from '../../redux/filters/slice'; + +import Chip from '@mui/material/Chip'; +import Stack from '@mui/material/Stack'; +import { FilterLabels } from '../../constants/types'; + +const FilterChips = () => { + const dispatch = useAppDispatch(); + const navigate = useNavigate(); + const location = useLocation(); + + const filterBreadcrumbs = useAppSelector(selectFilterBreadcrumbs); + + const handleChipDelete = (breadcrumbToDelete: ChipData) => { + const searchParams = new URLSearchParams(location.search); + dispatch(deleteFilterBreadcrumbs(breadcrumbToDelete)); + searchParams.delete(breadcrumbToDelete.key); + navigate({ + pathname: '/cases', + search: searchParams.toString(), + }); + }; + + return ( + + {filterBreadcrumbs.length > 0 && ( + dispatch(setModalOpen(true))} + /> + )} + {filterBreadcrumbs.map((breadcrumb) => ( + handleChipDelete(breadcrumb)} + onClick={() => { + dispatch(setModalOpen(true)); + dispatch(setActiveFilterInput(breadcrumb.key)); + }} + /> + ))} + + ); +}; + +export default FilterChips; diff --git a/verification/curator-service/ui/src/components/BundleOperations/Header.tsx b/verification/curator-service/ui/src/components/BundleOperations/Header.tsx new file mode 100644 index 000000000..e1f2b42d7 --- /dev/null +++ b/verification/curator-service/ui/src/components/BundleOperations/Header.tsx @@ -0,0 +1,86 @@ +import { useAppSelector, useAppDispatch } from '../../hooks/redux'; +import { selectDiseaseName } from '../../redux/app/selectors'; +import { setSort } from '../../redux/bundledCases/slice'; +import { selectSort } from '../../redux/bundledCases/selectors'; +import { SortBy, SortByOrder } from '../../constants/types'; + +import Typography from '@mui/material/Typography'; +import Stack from '@mui/material/Stack'; +import FormControl from '@mui/material/FormControl'; +import InputLabel from '@mui/material/InputLabel'; +import Select, { SelectChangeEvent } from '@mui/material/Select'; +import MenuItem from '@mui/material/MenuItem'; + +import FilterChips from './FilterChips'; + +const sortKeywords = [ + { + name: 'Identifier ascending', + value: SortBy.Identifier, + order: SortByOrder.Ascending, + }, + { + name: 'Identifier descending', + value: SortBy.Identifier, + order: SortByOrder.Descending, + }, +]; + +const Header = () => { + const dispatch = useAppDispatch(); + + const diseaseName = useAppSelector(selectDiseaseName); + const sort = useAppSelector(selectSort); + + const parseSelectValues = (value: SortBy, order: SortByOrder) => { + return `${value}|${order}`; + }; + + const handleChange = (event: SelectChangeEvent) => { + const value = event.target.value as string; + if (!value) return; + + const chosenSortByValue = value.split('|')[0]; + const chosenSortByOrder = value.split('|')[1]; + dispatch( + setSort({ + value: chosenSortByValue as SortBy, + order: chosenSortByOrder as SortByOrder, + }), + ); + }; + + return ( + + {diseaseName} Bundled Cases + + + Sort by + + + + + + ); +}; + +export default Header; diff --git a/verification/curator-service/ui/src/components/BundleOperations/Pagination.tsx b/verification/curator-service/ui/src/components/BundleOperations/Pagination.tsx new file mode 100644 index 000000000..4aec107e0 --- /dev/null +++ b/verification/curator-service/ui/src/components/BundleOperations/Pagination.tsx @@ -0,0 +1,97 @@ +import { useTheme } from '@mui/material/styles'; +import IconButton from '@mui/material/IconButton'; +import FirstPageIcon from '@mui/icons-material/FirstPage'; +import KeyboardArrowLeft from '@mui/icons-material/KeyboardArrowLeft'; +import KeyboardArrowRight from '@mui/icons-material/KeyboardArrowRight'; +import LastPageIcon from '@mui/icons-material/LastPage'; +import Box from '@mui/material/Box'; + +interface PaginationProps { + count: number; + page: number; + rowsPerPage: number; + onPageChange: ( + event: React.MouseEvent, + newPage: number, + ) => void; +} + +const Pagination = (props: PaginationProps) => { + const theme = useTheme(); + const { count, page, rowsPerPage, onPageChange } = props; + + const handleFirstPageButtonClick = ( + event: React.MouseEvent, + ) => { + onPageChange(event, 0); + }; + + const handleBackButtonClick = ( + event: React.MouseEvent, + ) => { + onPageChange(event, page - 1); + }; + + const handleNextButtonClick = ( + event: React.MouseEvent, + ) => { + onPageChange(event, page + 1); + }; + + const handleLastPageButtonClick = ( + event: React.MouseEvent, + ) => { + onPageChange(event, Math.max(0, Math.ceil(count / rowsPerPage) - 1)); + }; + + return ( + + + {theme.direction === 'rtl' ? ( + + ) : ( + + )} + + + {theme.direction === 'rtl' ? ( + + ) : ( + + )} + + = Math.ceil(count / rowsPerPage) - 1} + aria-label="next page" + > + {theme.direction === 'rtl' ? ( + + ) : ( + + )} + + = Math.ceil(count / rowsPerPage) - 1} + aria-label="last page" + > + {theme.direction === 'rtl' ? ( + + ) : ( + + )} + + + ); +}; + +export default Pagination; diff --git a/verification/curator-service/ui/src/components/BundleOperations/helperFunctions.ts b/verification/curator-service/ui/src/components/BundleOperations/helperFunctions.ts new file mode 100644 index 000000000..f9dc58c9b --- /dev/null +++ b/verification/curator-service/ui/src/components/BundleOperations/helperFunctions.ts @@ -0,0 +1,68 @@ +export const labels = [ + 'Bundle ID', + 'Case Count', + 'Verification Status', + 'Cases In Bundle', + 'Date Modified', + 'Last Modified By', + 'Case Status', + 'Entry date', + 'Reported date', + 'Country', + 'State', + 'Region', + 'District', + 'Location', + 'Age', + 'Gender', + 'Outcome', + 'Hospitalization date', + 'Symptoms onset date', + 'Source URL', +]; + +export const createData = ( + caseBundleId: string, + caseCount: number, + verificationStatus: boolean[], + casesInBundle: number[], + dateModified: string, + lastModifiedBy: string, + caseBundleStatus: string, + country: string, + admin1?: string, + admin2?: string, + admin3?: string, + location?: string, + dateEntry?: string, + dateReported?: string, + age?: string, + gender?: string, + outcome?: string, + dateHospitalization?: string, + dateOnset?: string, + source?: string, +) => { + return { + caseBundleId: caseBundleId || '', + caseCount: caseCount, + verificationStatus: verificationStatus || [], + casesInBundle: casesInBundle || [], + dateModified: dateModified || '', + lastModifiedBy: lastModifiedBy || '', + caseBundleStatus: caseBundleStatus || '', + dateEntry: dateEntry || '', + dateReported: dateReported || '', + country: country || '', + admin1: admin1 || '', + admin2: admin2 || '', + admin3: admin3 || '', + location: location || '', + age: age || '', + gender: gender || '', + outcome: outcome || '', + dateHospitalization: dateHospitalization || '', + dateOnset: dateOnset || '', + source: source || '', + }; +}; diff --git a/verification/curator-service/ui/src/components/BundleOperations/index.tsx b/verification/curator-service/ui/src/components/BundleOperations/index.tsx new file mode 100644 index 000000000..d3c1b930d --- /dev/null +++ b/verification/curator-service/ui/src/components/BundleOperations/index.tsx @@ -0,0 +1,575 @@ +import React, { useEffect } from 'react'; +import { Helmet } from 'react-helmet'; +import { useLocation, useNavigate } from 'react-router-dom'; + +import { + Accordion, + AccordionDetails, + AccordionSummary, + Checkbox, + CircularProgress, + Link, + Paper, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableFooter, + TableHead, + TablePagination, + TableRow, +} from '@mui/material'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; + +import { Role } from '../../api/models/User'; +import { CaseVerifyDialog } from '../Dialogs/CaseVerifyDialog'; +import { useAppDispatch, useAppSelector } from '../../hooks/redux'; +import { selectUser } from '../../redux/auth/selectors'; +import { + selectIsLoading, + selectCases, + selectCurrentPage, + selectError, + selectTotalCases, + selectRowsPerPage, + selectSort, + selectCasesSelected, + selectRefetchData, + selectRowsAcrossPages, + selectVerifyCasesDialogOpen, + selectDeleteCasesDialogOpen, + selectDeleteCasesSuccess, + selectVerifyCasesSuccess, +} from '../../redux/bundledCases/selectors'; +import { + setCurrentPage, + setRowsPerPage, + setCasesSelected, + setVerifyCasesDialogOpen, + setRowsAcrossPagesSelected, + setDeleteCasesDialogOpen, +} from '../../redux/bundledCases/slice'; +import { fetchBundlesData } from '../../redux/bundledCases/thunk'; +import { nameCountry } from '../util/countryNames'; +import renderDate from '../util/date'; +import { hasAnyRole, parseAgeRange } from '../util/helperFunctions'; +import { URLToSearchQuery } from '../util/searchQuery'; + +import EnhancedTableToolbar from './EnhancedTableToolbar'; +import { createData, labels } from './helperFunctions'; +import Pagination from './Pagination'; +import { + BundleOperationsContainer, + LoaderContainer, + StyledAlert, +} from './styled'; +import { CaseBundleDeleteDialog } from '../Dialogs/CaseBundleDeleteDialog'; + +const dataLimit = 10000; + +const BundleOperations = () => { + const dispatch = useAppDispatch(); + const navigate = useNavigate(); + const location = useLocation(); + + const isLoading = useAppSelector(selectIsLoading); + const cases = useAppSelector(selectCases); + const currentPage = useAppSelector(selectCurrentPage); + const totalCases = useAppSelector(selectTotalCases); + const error = useAppSelector(selectError); + const rowsPerPage = useAppSelector(selectRowsPerPage); + const sort = useAppSelector(selectSort); + const user = useAppSelector(selectUser); + const casesSelected = useAppSelector(selectCasesSelected); + const verifyCasesDialogOpen = useAppSelector(selectVerifyCasesDialogOpen); + const deleteCaseBundlesDialogOpen = useAppSelector( + selectDeleteCasesDialogOpen, + ); + const refetchData = useAppSelector(selectRefetchData); + const rowsAcrossPagesSelected = useAppSelector(selectRowsAcrossPages); + const verificationSuccess = useAppSelector(selectVerifyCasesSuccess); + const deletionSuccess = useAppSelector(selectDeleteCasesSuccess); + + const searchQuery = location.search; + + // Build query and fetch data + useEffect(() => { + const query = + searchQuery !== '' ? `&q=${URLToSearchQuery(searchQuery)}` : ''; + + const preparedQuery = `?page=${ + currentPage + 1 + }&limit=${rowsPerPage}&count_limit=${dataLimit}&sort_by=${ + sort.value + }&order=${sort.order}${query}`; //&verification_status=${false} + + dispatch(fetchBundlesData(preparedQuery)); + }, [ + dispatch, + currentPage, + rowsPerPage, + sort, + searchQuery, + refetchData, + verificationSuccess, + deletionSuccess, + ]); + + // When user applies filters we should go back to the first page of results + useEffect(() => { + if ( + currentPage === 0 || + (location.state && location.state.lastLocation === '/case/view') + ) + return; + + dispatch(setCurrentPage(0)); + // eslint-disable-next-line + }, [dispatch, searchQuery]); + + const rows = + cases && + cases.map((data: any) => { + return createData( + data._id || '', + data.caseCount, + data.verificationStatus, + data.caseIds, + renderDate(data.dateModified) || renderDate(data.dateCreated), + data.modifiedBy || data.createdBy, + data.caseStatus, + nameCountry(data.countryISO3, data.country) || '-', + data.admin1 || '-', + data.admin2 || '-', + data.admin3 || '-', + data.location || '-', + renderDate(data.dateEntry) || '-', + renderDate(data.dateReported) || '-', + parseAgeRange(data.ageRange) || '-', + data.gender || '-', + data.outcome || '-', + renderDate(data.dateHospitalization) || '-', + renderDate(data.dateOnset) || '-', + data.sourceUrl || '-', + ); + }); + + const handleChangePage = ( + event: React.MouseEvent | null, + newPage: number, + ) => { + dispatch(setCurrentPage(newPage)); + }; + + const handleChangeRowsPerPage = ( + event: React.ChangeEvent, + ) => { + dispatch(setRowsPerPage(parseInt(event.target.value, 10))); + dispatch(setCurrentPage(0)); + }; + + const customPaginationLabel = ({ + from, + to, + count, + }: { + from: number; + to: number; + count: number; + }) => { + return `${from} - ${to} of ${count >= dataLimit ? 'many' : `${count}`}`; + }; + + const handleBundleClick = (bundleId: string) => { + navigate(`/cases/bundle/view/${bundleId}`, { + state: { + lastLocation: location.pathname, + }, + }); + }; + + const handleSelectAllClick = ( + event: React.ChangeEvent, + ) => { + if (event.target.checked) { + const newSelected = rows.map((n) => n.caseBundleId); + dispatch(setCasesSelected(newSelected)); + return; + } + dispatch(setCasesSelected([])); + dispatch(setRowsAcrossPagesSelected(false)); + }; + + const handleCaseSelect = ( + event: React.MouseEvent, + caseId: string, + ) => { + const selectedIndex = casesSelected.indexOf(caseId); + let newSelected: string[]; + + if (selectedIndex === -1) { + newSelected = [...casesSelected, caseId]; + } else { + newSelected = casesSelected.filter((id) => id !== caseId); + } + + dispatch(setCasesSelected(newSelected)); + + // In order to stop opening case details after clicking on a checkbox + event.stopPropagation(); + }; + + const isSelected = (id: string) => casesSelected.indexOf(id) !== -1; + + const handleCaseClick = (caseId: number) => { + navigate(`/cases/view/${caseId}`, { + state: { + lastLocation: location.pathname, + }, + }); + }; + + return ( + + + Global.health | Cases + + + {error && ( + + {error} + + )} + + {!location.state?.bulkMessage && location.state?.editedBundleId && ( + + handleBundleClick(location.state.editedBundleId) + } + style={{ cursor: 'pointer' }} + > + VIEW + + } + > + {`Case bundle ${location.state.editedBundleId} edited`} + + )} + + + + + {isLoading && ( + + + + )} + + + + + + {hasAnyRole(user, [ + Role.Admin, + Role.Curator, + ]) && ( + + 0 && + casesSelected.length < + rows.length + } + checked={ + rows.length > 0 && + casesSelected.length === + rows.length + } + onChange={handleSelectAllClick} + inputProps={{ + 'aria-label': + 'select all cases', + }} + /> + + )} + + {labels.map((label) => ( + + {label} + + ))} + + + + {rows.length > 0 ? ( + rows.map((row, idx) => ( + + handleBundleClick(row.caseBundleId) + } + > + {hasAnyRole(user, [ + Role.Admin, + Role.Curator, + ]) && ( + <> + + + handleCaseSelect( + e, + row.caseBundleId, + ) + } + /> + + + )} + + {row.caseBundleId} + + + {row.caseCount} + + + {row.verificationStatus.filter(Boolean).length}/{row.verificationStatus.length} + + + + e.stopPropagation() + } + > + + } + aria-controls="panel1-content" + id="panel1-header" + > + Case IDs + + + {row.casesInBundle.map( + (caseId) => ( +
+ + handleCaseClick( + caseId, + ) + } + > + {caseId} + + {'\t'} +
+ ), + )} +
+
+
+ + {row.dateModified} + + + {row.lastModifiedBy} + + + {row.caseBundleStatus} + + + {row.dateEntry} + + + {row.dateReported} + + + {row.country} + + + {row.admin1} + + + {row.admin2} + + + {row.admin3} + + + {row.location} + + + {row.age} + + + {row.gender} + + + {row.outcome} + + + {row.dateHospitalization} + + + {row.dateOnset} + + + {row.source} + +
+ )) + ) : ( + + + No records to display + + + )} +
+
+
+
+ + + + + + + + +
+
+ + dispatch(setVerifyCasesDialogOpen(false))} + caseBundleIds={ + rowsAcrossPagesSelected ? undefined : casesSelected + } + query={rowsAcrossPagesSelected ? searchQuery : undefined} + /> + + dispatch(setDeleteCasesDialogOpen(false))} + bundleIds={rowsAcrossPagesSelected ? undefined : casesSelected} + query={rowsAcrossPagesSelected ? searchQuery : undefined} + /> +
+ ); +}; + +export default BundleOperations; diff --git a/verification/curator-service/ui/src/components/BundleOperations/styled.ts b/verification/curator-service/ui/src/components/BundleOperations/styled.ts new file mode 100644 index 000000000..84f2ca3d7 --- /dev/null +++ b/verification/curator-service/ui/src/components/BundleOperations/styled.ts @@ -0,0 +1,23 @@ +import { styled } from '@mui/material/styles'; +import { Alert } from '@mui/material'; + +export const LoaderContainer = styled('div')(() => ({ + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: '100%', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(255, 255, 255, 0.5)', + zIndex: 1000, +})); + +export const StyledAlert = styled(Alert)(() => ({ + marginTop: '2rem', +})); + +export const BundleOperationsContainer = styled('div')(() => ({ + marginTop: '64px', +})); diff --git a/verification/curator-service/ui/src/components/CaseForm.tsx b/verification/curator-service/ui/src/components/CaseForm.tsx index f9e9a18d4..37263797a 100644 --- a/verification/curator-service/ui/src/components/CaseForm.tsx +++ b/verification/curator-service/ui/src/components/CaseForm.tsx @@ -252,6 +252,7 @@ const initialValuesFromCase = ( interface Props { initialCase?: Day0Case; + bundleId?: string; onModalClose: () => void; diseaseName: string; } @@ -504,8 +505,15 @@ export default function CaseForm(props: Props): JSX.Element { let newCaseIds = []; try { + // Update case bundle + if (props.bundleId) { + await axios.put( + `/api/cases/bundled/${props.bundleId}`, + newCase, + ); + } // Update or create depending on the presence of the initial case ID. - if (props.initialCase?._id) { + else if (props.initialCase?._id) { await axios.put( `/api/cases/${props.initialCase?._id}`, newCase, @@ -529,15 +537,20 @@ export default function CaseForm(props: Props): JSX.Element { setErrorMessage(e.response?.data?.message || e.toString()); return; } - // Navigate to cases after successful submit - navigate('/cases', { - state: { - newCaseIds: newCaseIds, - editedCaseIds: props.initialCase?._id - ? [props.initialCase._id] - : [], - }, - }); + if (props.bundleId) { + // Navigate to cases after successful submit + navigate('/bundles', { state: { editedBundleId: props.bundleId } }); + } else { + // Navigate to cases after successful submit + navigate('/cases', { + state: { + newCaseIds: newCaseIds, + editedCaseIds: props.initialCase?._id + ? [props.initialCase._id] + : [], + }, + }); + } }; const tableOfContentsIcon = (opts: { @@ -952,6 +965,9 @@ export default function CaseForm(props: Props): JSX.Element { Complete all available data for the case. Required fields are marked. + {props.initialCase && + Editing the individual case that is part of the bundle will cause it to be removed from the bundle. + }
diff --git a/verification/curator-service/ui/src/components/Dialogs/CaseBundleDeleteDialog.tsx b/verification/curator-service/ui/src/components/Dialogs/CaseBundleDeleteDialog.tsx new file mode 100644 index 000000000..3df328984 --- /dev/null +++ b/verification/curator-service/ui/src/components/Dialogs/CaseBundleDeleteDialog.tsx @@ -0,0 +1,100 @@ +import { + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, +} from '@mui/material'; +import { useTheme } from '@mui/material/styles'; + +import { useAppDispatch, useAppSelector } from '../../hooks/redux'; +import { + selectIsLoading, + selectTotalCases, +} from '../../redux/linelistTable/selectors'; +import { deleteCaseBundles } from '../../redux/bundledCases/thunk'; + +interface CaseBundleDeleteDialogProps { + isOpen: boolean; + handleClose: () => void; + bundleIds: string[] | undefined; + query: string | undefined; +} + +export const CaseBundleDeleteDialog = ({ + isOpen, + handleClose, + bundleIds, + query, +}: CaseBundleDeleteDialogProps) => { + const dispatch = useAppDispatch(); + const theme = useTheme(); + + const isLoading = useAppSelector(selectIsLoading); + const totalCases = useAppSelector(selectTotalCases); + + const renderTitle = () => { + if (bundleIds) { + return `Are you sure you want to delete ${ + bundleIds.length === 1 + ? '1 case bundle' + : `${bundleIds.length} case bundles` + }?`; + } else { + return `Are you sure you want to delete ${totalCases} case bundles?`; + } + }; + + const renderContent = () => { + if (bundleIds) { + return `${ + bundleIds.length === 1 + ? '1 case bundle' + : `${bundleIds.length} case bundles` + } will be permanently deleted.`; + } else { + return `${totalCases} case bundles will be permanently deleted.`; + } + }; + + return ( + e.stopPropagation()} + > + {renderTitle()} + + {renderContent()} + + + {isLoading ? ( + + ) : ( + <> + + + + + )} + + + ); +}; diff --git a/verification/curator-service/ui/src/components/Dialogs/CaseVerifyDialog.tsx b/verification/curator-service/ui/src/components/Dialogs/CaseVerifyDialog.tsx new file mode 100644 index 000000000..c36fc29a4 --- /dev/null +++ b/verification/curator-service/ui/src/components/Dialogs/CaseVerifyDialog.tsx @@ -0,0 +1,99 @@ +import { + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, +} from '@mui/material'; +import { useTheme } from '@mui/material/styles'; + +import { useAppDispatch, useAppSelector } from '../../hooks/redux'; +import { + selectIsLoading, + selectTotalCases, + selectVerifyCasesLoading, + selectVerifyCasesSuccess, +} from '../../redux/bundledCases/selectors'; +import { verifyCaseBundle } from '../../redux/bundledCases/thunk'; +import {useEffect} from "react"; + +interface CaseDeleteDialogProps { + isOpen: boolean; + handleClose: () => void; + caseBundleIds: string[] | undefined; + query: string | undefined; +} + +export const CaseVerifyDialog = ({ + isOpen, + handleClose, + caseBundleIds, + query, +}: CaseDeleteDialogProps) => { + const dispatch = useAppDispatch(); + const theme = useTheme(); + + const isLoading = useAppSelector(selectIsLoading); + const totalCases = useAppSelector(selectTotalCases); + const verificationLoading = useAppSelector(selectVerifyCasesLoading); + + const renderTitle = () => { + if (caseBundleIds) { + return `Verify ${ + caseBundleIds.length === 1 ? '1 case bundle' : `${caseBundleIds.length} case bundles` + }?`; + } else { + return `Verify ${totalCases} case bundles?`; + } + }; + + const renderContent = () => { + if (caseBundleIds) { + return `${ + caseBundleIds.length === 1 ? '1 case bundle' : `${caseBundleIds.length} case bundles` + } will marked as verified.`; + } else { + return `${totalCases} case bundles will be marked as verified.`; + } + }; + + return ( + e.stopPropagation()} + > + {renderTitle()} + + {renderContent()} + + + {isLoading ? ( + + ) : ( + <> + + + + + )} + + + ); +}; diff --git a/verification/curator-service/ui/src/components/EditCaseBundle.tsx b/verification/curator-service/ui/src/components/EditCaseBundle.tsx new file mode 100644 index 000000000..cc1ddd394 --- /dev/null +++ b/verification/curator-service/ui/src/components/EditCaseBundle.tsx @@ -0,0 +1,53 @@ +import { useEffect, useState } from 'react'; +import CaseForm from './CaseForm'; +import { LinearProgress } from '@mui/material'; +import MuiAlert from '@mui/material/Alert'; +import axios from 'axios'; +import { useParams } from 'react-router-dom'; +import { Day0Case } from '../api/models/Day0Case'; + +interface Props { + onModalClose: () => void; + diseaseName: string; +} + +export default function EditCaseBundle(props: Props): JSX.Element { + const { id } = useParams(); + const [c, setCase] = useState(); + const [loading, setLoading] = useState(false); + const [errorMessage, setErrorMessage] = useState(); + + useEffect(() => { + setLoading(true); + axios + .get(`/api/cases/bundled/${id}`) + .then((resp) => { + setCase(resp.data[0]); + setErrorMessage(undefined); + }) + .catch((e) => { + setCase(undefined); + setErrorMessage(e.response?.data?.message || e.toString()); + }) + .finally(() => setLoading(false)); + }, [id]); + + return ( +
+ {loading && } + {errorMessage && ( + + {errorMessage} + + )} + {c && ( + + )} +
+ ); +} diff --git a/verification/curator-service/ui/src/components/Sidebar/index.tsx b/verification/curator-service/ui/src/components/Sidebar/index.tsx index c398d5aa9..8bab0d87a 100644 --- a/verification/curator-service/ui/src/components/Sidebar/index.tsx +++ b/verification/curator-service/ui/src/components/Sidebar/index.tsx @@ -13,6 +13,7 @@ import { } from '@mui/material'; import { Add as AddIcon, + ListAlt as BundleOperationsIcon, Link as LinkIcon, List as ListIcon, People as PeopleIcon, @@ -73,6 +74,13 @@ const Sidebar = ({ drawerOpen }: SidebarProps): JSX.Element => { to: { pathname: '/cases', search: '' }, displayCheck: (): boolean => true, }, + { + text: 'Bundle Operations', + icon: , + to: '/bundles', + displayCheck: (): boolean => + hasAnyRole(user, [Role.Curator]), + }, { text: 'Sources', icon: , diff --git a/verification/curator-service/ui/src/components/ViewBundle.tsx b/verification/curator-service/ui/src/components/ViewBundle.tsx new file mode 100644 index 000000000..77c1f75e9 --- /dev/null +++ b/verification/curator-service/ui/src/components/ViewBundle.tsx @@ -0,0 +1,1216 @@ +import axios from 'axios'; +import React, { useEffect, useState } from 'react'; +import Highlighter from 'react-highlight-words'; +import { useSelector } from 'react-redux'; +import { useNavigate, useParams } from 'react-router-dom'; +import Scroll from 'react-scroll'; +import { makeStyles } from 'tss-react/mui'; +import { + Close as CloseIcon, + EditOutlined as EditIcon, + CheckCircleOutline as VerifyIcon, + DeleteOutline as DeleteIcon, +} from '@mui/icons-material'; +import { + Accordion, + AccordionDetails, + AccordionSummary, + Alert, + Button, + Chip, + Dialog, + DialogContent, + DialogTitle, + Grid, + IconButton, + LinearProgress, + Link, + Paper, + Typography, + useMediaQuery, +} from '@mui/material'; +import { useTheme } from '@mui/material/styles'; + +import { Day0Case, Outcome, YesNo } from '../api/models/Day0Case'; +import { Role } from '../api/models/User'; +import AppModal from './AppModal'; +import renderDate from './util/date'; +import createHref from './util/links'; +import { selectFilterBreadcrumbs } from '../redux/app/selectors'; +import { selectUser } from '../redux/auth/selectors'; +import { selectSearchQuery } from '../redux/linelistTable/selectors'; +import { nameCountry } from './util/countryNames'; +import { parseAgeRange } from './util/helperFunctions'; +import { useAppDispatch } from '../hooks/redux'; +import { verifyCaseBundle } from '../redux/bundledCases/thunk'; +import { + selectDeleteCasesLoading, + selectDeleteCasesSuccess, + selectVerifyCasesLoading, + selectVerifyCasesSuccess, +} from '../redux/bundledCases/selectors'; +import { LoadingButton } from '@mui/lab'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; + +const styles = makeStyles()(() => ({ + errorMessage: { + height: 'fit-content', + width: '100%', + }, +})); + +interface Props { + enableEdit?: boolean; + onModalClose: () => void; +} + +export default function ViewBundle(props: Props): JSX.Element { + const { id } = useParams(); + const [cases, setCases] = useState(); + const [loading, setLoading] = useState(false); + const [errorMessage, setErrorMessage] = useState(); + + useEffect(() => { + setLoading(true); + axios + .get(`/api/cases/bundled/${id}`) + .then((resp) => { + setCases(resp.data); + setErrorMessage(undefined); + }) + .catch((e) => { + setCases([]); + setErrorMessage(e.response?.data?.message || e.toString()); + }) + .finally(() => setLoading(false)); + }, [id]); + + const deleteCase = ( + onSuccess: () => void, + onError: (errorMessage: string) => void, + caseId: string, + verifierEmail: string, + ) => { + if (caseId && verifierEmail) { + setLoading(true); + axios + .delete(`/api/cases/bundled/${caseId}`) + .then((resp) => { + props.onModalClose(); + }) + .catch((e) => { + onError(e.response?.data?.message || e.toString()); + }) + .finally(() => { + setLoading(false); + }); + } + }; + + const verifyCase = ( + onSuccess: () => void, + onError: (errorMessage: string) => void, + caseId: string, + verifierEmail: string, + ) => { + if (caseId && verifierEmail) { + setLoading(true); + axios + .post(`/api/cases/verify/bundled/${caseId}`, { + email: verifierEmail, + }) + .then((resp) => { + setCases(resp.data); + onSuccess(); + }) + .catch((e) => { + onError(e.response?.data?.message || e.toString()); + }) + .finally(() => { + setLoading(false); + }); + } + }; + + const { classes } = styles(); + + return ( + + {loading && } + {errorMessage && ( + + {errorMessage} + + )} + {cases && cases.length > 0 && ( + + )} + + ); +} +interface CaseDetailsProps { + bundleId: string; + cases: Day0Case[]; + verifyCase: ( + onSuccess: () => void, + onError: (errorMessage: string) => void, + caseId: string, + userEmail: string, + ) => void; + deleteCase: ( + onSuccess: () => void, + onError: (errorMessage: string) => void, + caseId: string, + userEmail: string, + ) => void; + enableEdit?: boolean; +} + +const useStyles = makeStyles()((theme) => ({ + paper: { + background: theme.palette.background.paper, + marginTop: '1em', + }, + caseLink: { + // marginRight: '10px', + cursor: 'pointer', + }, + caseTitle: { + marginTop: '1em', + marginBottom: '1em', + fontFamily: 'Inter', + }, + grid: { + margin: '1em', + }, + sectionTitle: { + margin: '1em', + }, + container: { + marginTop: '1em', + marginBottom: '1em', + }, + actionButton: { + marginLeft: '1em', + }, + navMenu: { + position: 'fixed', + lineHeight: '2em', + width: '10em', + textTransform: 'uppercase', + }, + alert: { + backgroundColor: theme.palette.background.paper, + borderRadius: theme.spacing(1), + marginTop: theme.spacing(1), + }, + casebox: { + paddingRight: '20px', + wordBreak: 'break-all', + }, + breadcrumbChip: { + margin: theme.spacing(0.5), + marginRight: '8px', + }, + dialogContainer: { + height: '40%', + }, + dialogTitle: { + display: 'flex', + }, + closeButton: { + position: 'absolute', + right: 8, + top: 8, + }, + dialogButton: { + margin: '1em', + }, +})); + +function CaseDetails(props: CaseDetailsProps): JSX.Element { + const theme = useTheme(); + const navigate = useNavigate(); + const showNavMenu = useMediaQuery(theme.breakpoints.up('sm')); + const [verifyDialogOpen, setVerifyDialogOpen] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const { classes } = useStyles(); + const dispatch = useAppDispatch(); + const verificationLoading = useSelector(selectVerifyCasesLoading); + const verificationSuccess = useSelector(selectVerifyCasesSuccess); + + const deleteLoading = useSelector(selectDeleteCasesLoading); + const deleteSuccess = useSelector(selectDeleteCasesSuccess); + + useEffect(() => { + if (verificationSuccess) { + setVerifyDialogOpen(false); + } + }, [verificationSuccess]); + const unverified = props.cases.some((c) => !c.curators?.verifiedBy); + const { bundleId } = props; + const caseData = props.cases[0]; + + const scrollTo = (name: string): void => { + Scroll.scroller.scrollTo(name, { + duration: 100, + smooth: true, + containerId: 'scroll-container', + }); + }; + + const handleCaseClick = (caseId: string) => { + navigate(`/cases/view/${caseId}`, { + state: { + lastLocation: location.pathname, + }, + }); + }; + + const handleEditBundleClick = (bundleId: string) => { + navigate(`/cases/bundle/edit/${bundleId}`, { + state: { + lastLocation: location.pathname, + }, + }); + }; + + const searchedKeywords = useSelector(selectSearchQuery); + const filtersBreadcrumb = useSelector(selectFilterBreadcrumbs); + const user = useSelector(selectUser); + + return ( + <> + {showNavMenu && ( + + )} +
+ {searchedKeywords && ( + <> + + + {filtersBreadcrumb.map((breadcrumb) => ( + + ))} + + )} + + Case bundle {bundleId} + {props.cases.length > 0 && + unverified && + user?.roles.includes(Role.Curator) && ( + <> + + + Are you sure? + + setVerifyDialogOpen(false) + } + className={classes.closeButton} + id="small-screens-popup-close-btn" + size="large" + data-testid="verify-dialog-close-button" + > + + + + + + Before verifying make sure that all + the case data is valid. + + + } + onClick={() => + props.verifyCase( + () => + setVerifyDialogOpen(false), + (errorMessage: string) => { + console.log(errorMessage); + }, + bundleId || '', + user.email || '', + ) + } + loading={verificationLoading} + > + Verify + + + + + )} + {props.enableEdit && ( + + )} + {props.cases.length > 0 && + user?.roles.includes(Role.Curator) && ( + <> + + + Are you sure? + + setDeleteDialogOpen(false) + } + className={classes.closeButton} + id="small-screens-popup-close-btn" + size="large" + data-testid="delete-dialog-close-button" + > + + + + + + Before deleting make sure that correct bundle was selected. + + + } + onClick={() => + props.deleteCase( + () => + setDeleteDialogOpen(false), + (errorMessage: string) => { + console.log(errorMessage); + }, + bundleId || '', + user.email || '', + ) + } + loading={deleteLoading} + > + Delete + + + + + )} + + e.stopPropagation()}> + } + aria-controls="panel1-content" + id="panel1-header" + > + Contains {props.cases.length} case + {props.cases.length > 1 ? 's' : ''} + + + {props.cases.map((c) => ( +
+ handleCaseClick(c._id || '')} + className={classes.caseLink} + > + {c._id} + + {'\t'} +
+ ))} +
+
+ {/* CURATORS */} + + + + Curators + + + + + + + + + + {/* CASE DATA */} + + + + Case data + + + + + + + + {caseData.caseReference.additionalSources && + caseData.caseReference.additionalSources + .length > 0 && + caseData.caseReference.additionalSources.map( + (source, idx) => ( + <> + + + + ), + )} + + + + + + + + + + + + + + + + + + + {/* LOCATION */} + + + + Location + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {/* EVENT HISTORY */} + + + + Event history + + + + + + + + + + + + + + + + + + + + + {caseData.events.isolated === YesNo.Y && ( + <> + + + + )} + + + + + {caseData.events.hospitalized === YesNo.Y && ( + <> + + + + + + )} + + + + + {caseData.events.intensiveCare === YesNo.Y && ( + <> + + + + + + + )} + + + + + {caseData.events.outcome && ( + <> + + + + )} + + + + + {/* DEMOGRAPHICS */} + + + + Demographics + + + + + + + + + + + + + + + + + {/* SYMPTOMS */} + + + + Symptoms + + + + + + + + + {/* PREEXISTING CONDITIONS */} + + + Preexisting conditions + + + + + + + + + + + + + + + + + {/* TRANSMISSION */} + + + + Transmission + + + + + + + + + + + + + + + + + + + + + + + + {/* TRAVEL HISTORY */} + + + + Travel history + + + + + + + + + + + + + + + + + + + + + {/* PATHOGENS */} + + + + Pathogens & genome sequencing + + + + + + + + + + + + + + + {/* VACCINES */} + + + + Vaccines + + + + + + + + + + + + + + + + +
+ + ); +} + +function RowHeader(props: { title: string }): JSX.Element { + return ( + + {props.title} + + ); +} + +function RowContent(props: { + content?: string; + isLink?: boolean; + isMultiline?: boolean; + linkComment?: string; +}): JSX.Element { + const searchQuery = useSelector(selectSearchQuery); + const searchQueryArray: string[] = []; + + function words(s: string) { + const q = new URLSearchParams(s).get('q'); + if (!q) return; + + const quoted: string[] = []; + const notQuoted: string[] = []; + if (q.includes('"') && q.replace(/[^"]/g, '').length % 2 !== 1) { + q.split('"').map((subs: string, i: number) => { + subs != '' && i % 2 ? quoted.push(subs) : notQuoted.push(subs); + }); + } else notQuoted.push(q); + + const regex = /"([^"]+)"|(\S{1,})/g; + // Make sure that terms in quotes will be highlighted as one search term + for (const quotedEntry of quoted) { + let match; + let accumulator: string[] = []; + while ((match = regex.exec(quotedEntry))) { + accumulator.push(match[match[1] ? 1 : 2]); + } + searchQueryArray.push(accumulator.join(' ')); + } + for (const notQuotedEntry of notQuoted) { + let match; + while ((match = regex.exec(notQuotedEntry))) { + searchQueryArray.push(match[match[1] ? 1 : 2]); + } + } + } + words(searchQuery); + + return ( + + {props.isLink && props.content ? ( + + + {props.linkComment && ` (${props.linkComment})`} + + ) : ( + + )} + + ); +} diff --git a/verification/curator-service/ui/src/redux/bundledCases/selectors.ts b/verification/curator-service/ui/src/redux/bundledCases/selectors.ts new file mode 100644 index 000000000..c24904cf4 --- /dev/null +++ b/verification/curator-service/ui/src/redux/bundledCases/selectors.ts @@ -0,0 +1,38 @@ +import { RootState } from '../store'; + +export const selectIsLoading = (state: RootState) => state.bundledCases.isLoading; +export const selectCases = (state: RootState) => state.bundledCases.cases; +export const selectCurrentPage = (state: RootState) => + state.bundledCases.currentPage; +export const selectNextPage = (state: RootState) => state.bundledCases.nextPage; +export const selectTotalCases = (state: RootState) => state.bundledCases.total; +export const selectError = (state: RootState) => state.bundledCases.error; +export const selectRowsPerPage = (state: RootState) => + state.bundledCases.rowsPerPage; +export const selectSort = (state: RootState) => state.bundledCases.sort; +export const selectSearchQuery = (state: RootState) => + state.bundledCases.searchQuery; +export const selectExcludeCasesDialogOpen = (state: RootState) => + state.bundledCases.excludeCasesDialogOpen; +export const selectCasesSelected = (state: RootState) => + state.bundledCases.casesSelected; +export const selectDeleteCasesDialogOpen = (state: RootState) => + state.bundledCases.deleteCasesDialogOpen; +export const selectDeleteCasesLoading = (state: RootState) => + state.bundledCases.deleteCasesLoading; +export const selectDeleteCasesSuccess = (state: RootState) => + state.bundledCases.deleteCasesSuccess; +export const selectVerifyCasesDialogOpen = (state: RootState) => + state.bundledCases.verifyCasesDialogOpen; +export const selectVerifyCasesLoading = (state: RootState) => + state.bundledCases.verifyCasesLoading; +export const selectVerifyCasesSuccess = (state: RootState) => + state.bundledCases.verifyCasesSuccess; +export const selectReincludeCasesDialogOpen = (state: RootState) => + state.bundledCases.reincludeCasesDialogOpen; +export const selectRefetchData = (state: RootState) => + state.bundledCases.refetchData; +export const selectVerificationStatus = (state: RootState) => + state.bundledCases.verificationStatus; +export const selectRowsAcrossPages = (state: RootState) => + state.bundledCases.rowsAcrossPagesSelected; diff --git a/verification/curator-service/ui/src/redux/bundledCases/slice.ts b/verification/curator-service/ui/src/redux/bundledCases/slice.ts new file mode 100644 index 000000000..40833da8e --- /dev/null +++ b/verification/curator-service/ui/src/redux/bundledCases/slice.ts @@ -0,0 +1,171 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import {deleteCaseBundles, fetchBundlesData, verifyCaseBundle} from './thunk'; +import { VerificationStatus } from '../../api/models/Case'; +import { Day0Case } from '../../api/models/Day0Case'; +import { SortBy, SortByOrder } from '../../constants/types'; + +interface BundledCasesTableState { + isLoading: boolean; + cases: Day0Case[]; + currentPage: number; + nextPage: number; + rowsPerPage: number; + sort: { + value: SortBy; + order: SortByOrder; + }; + searchQuery: string; + total: number; + error: string | undefined; + excludeCasesDialogOpen: boolean; + deleteCasesDialogOpen: boolean; + deleteCasesLoading: boolean; + deleteCasesSuccess: boolean | undefined; + verifyCasesDialogOpen: boolean; + verifyCasesLoading: boolean; + verifyCasesSuccess: boolean | undefined; + reincludeCasesDialogOpen: boolean; + casesSelected: string[]; + refetchData: boolean; + verificationStatus: VerificationStatus | undefined; + rowsAcrossPagesSelected: boolean; +} + +const initialState: BundledCasesTableState = { + isLoading: false, + cases: [], + currentPage: 0, + nextPage: 1, + rowsPerPage: 50, + sort: { + value: SortBy.Identifier, + order: SortByOrder.Descending, + }, + searchQuery: '', + total: 0, + error: undefined, + excludeCasesDialogOpen: false, + deleteCasesDialogOpen: false, + deleteCasesLoading: false, + deleteCasesSuccess: false, + verifyCasesDialogOpen: false, + verifyCasesLoading: false, + verifyCasesSuccess: undefined, + reincludeCasesDialogOpen: false, + casesSelected: [], + refetchData: false, + verificationStatus: undefined, + rowsAcrossPagesSelected: false, +}; + +const bundledCasesTableSlice = createSlice({ + name: 'bulkVerificationList', + initialState, + reducers: { + setCurrentPage: (state, action: PayloadAction) => { + state.currentPage = action.payload; + }, + setRowsPerPage: (state, action: PayloadAction) => { + state.rowsPerPage = action.payload; + }, + setSort: ( + state, + action: PayloadAction<{ value: SortBy; order: SortByOrder }>, + ) => { + state.sort = action.payload; + }, + setSearchQuery: (state, action: PayloadAction) => { + state.searchQuery = action.payload; + }, + setCasesSelected: (state, action: PayloadAction) => { + state.casesSelected = action.payload; + }, + setDeleteCasesDialogOpen: (state, action: PayloadAction) => { + state.deleteCasesDialogOpen = action.payload; + }, + setVerifyCasesDialogOpen: (state, action: PayloadAction) => { + state.verifyCasesDialogOpen = action.payload; + }, + setReincludeCasesDialogOpen: ( + state, + action: PayloadAction, + ) => { + state.reincludeCasesDialogOpen = action.payload; + }, + setVerificationStatus: ( + state, + action: PayloadAction, + ) => { + state.verificationStatus = action.payload; + }, + setRowsAcrossPagesSelected: (state, action: PayloadAction) => { + state.rowsAcrossPagesSelected = action.payload; + }, + }, + extraReducers: (builder) => { + // FETCH CASE BUNDLES + builder.addCase(fetchBundlesData.pending, (state) => { + state.isLoading = true; + state.error = undefined; + }); + builder.addCase(fetchBundlesData.fulfilled, (state, { payload }) => { + state.isLoading = false; + state.cases = payload.cases; + state.nextPage = payload.nextPage; + state.total = payload.total; + }); + builder.addCase(fetchBundlesData.rejected, (state, action) => { + state.isLoading = false; + state.error = action.payload + ? action.payload + : action.error.message; + }); + // VERIFY CASE BUNDLES + builder.addCase(verifyCaseBundle.pending, (state) => { + state.verifyCasesLoading = true; + state.verifyCasesSuccess = undefined; + }); + builder.addCase(verifyCaseBundle.fulfilled, (state, { payload }) => { + state.verifyCasesLoading = false; + state.verifyCasesSuccess = true; + state.verifyCasesDialogOpen = false; + state.casesSelected = []; + }); + builder.addCase(verifyCaseBundle.rejected, (state, action) => { + state.verifyCasesLoading = false; + state.verifyCasesSuccess = false; + }); + // DELETE CASE BUNDLES + builder.addCase(deleteCaseBundles.pending, (state) => { + state.deleteCasesLoading = true; + state.deleteCasesSuccess = undefined; + }); + builder.addCase(deleteCaseBundles.fulfilled, (state, { payload }) => { + state.deleteCasesLoading = false; + state.deleteCasesSuccess = true; + state.deleteCasesDialogOpen = false; + state.casesSelected = []; + }); + builder.addCase(deleteCaseBundles.rejected, (state, action) => { + state.deleteCasesLoading = false; + state.deleteCasesSuccess = false; + }); + }, + +}); + +// actions +export const { + setCurrentPage, + setRowsPerPage, + setSort, + setSearchQuery, + setCasesSelected, + setDeleteCasesDialogOpen, + setVerifyCasesDialogOpen, + setReincludeCasesDialogOpen, + setVerificationStatus, + setRowsAcrossPagesSelected, +} = bundledCasesTableSlice.actions; + +export default bundledCasesTableSlice.reducer; diff --git a/verification/curator-service/ui/src/redux/bundledCases/thunk.ts b/verification/curator-service/ui/src/redux/bundledCases/thunk.ts new file mode 100644 index 000000000..53de49820 --- /dev/null +++ b/verification/curator-service/ui/src/redux/bundledCases/thunk.ts @@ -0,0 +1,90 @@ +import { createAsyncThunk } from '@reduxjs/toolkit'; +import { Day0Case } from '../../api/models/Day0Case'; +import axios from 'axios'; + +interface ListResponse { + caseBundles: Day0Case[]; + nextPage: number; + total: number; +} + +export const fetchBundlesData = createAsyncThunk< + { cases: Day0Case[]; nextPage: number; total: number }, + string | undefined, + { rejectValue: string } +>('bundleOperationsList/fetchBundlesData', async (query, { rejectWithValue }) => { + + try { + const response = await axios.get( + `/api/cases/bundled${query ? query : ''}`, + ); + + const cases = response.data.caseBundles as Day0Case[]; + + return { + cases, + nextPage: response.data.nextPage, + total: response.data.total, + }; + } catch (error) { + if (!error.response) throw error; + return rejectWithValue( + `Error: Request failed with status code ${error.response.status}`, + ); + } +}); + +export const verifyCaseBundle = createAsyncThunk< + void, + { caseBundleIds?: string[]; query?: string }, + { rejectValue: string } +>('bundleOperations/verifyCaseBundles', async (args, { rejectWithValue }) => { + const { caseBundleIds, query } = args; + + try { + const parsedQuery = + query && query.replace('?', '').replaceAll('=', ':'); + + const response = await axios.post('/api/cases/verify/bundled', { + data: { caseBundleIds, query: parsedQuery }, + }); + + if (response.status !== 204) throw new Error(response.data.message); + + return; + } catch (error) { + if (!error.response) throw error; + + return rejectWithValue( + `Error: Request failed with status code ${error.response.status}`, + ); + } +}); + + +export const deleteCaseBundles = createAsyncThunk< + void, + { bundleIds?: string[]; query?: string }, + { rejectValue: string } +>('bundleOperations/deleteCaseBundles', async (args, { rejectWithValue }) => { + const { bundleIds, query } = args; + + try { + const parsedQuery = + query && query.replace('?', '').replaceAll('=', ':'); + + const response = await axios.delete('/api/cases/bundled', { + data: { bundleIds, query: parsedQuery }, + }); + + if (response.status !== 204) throw new Error(response.data.message); + + return; + } catch (error) { + if (!error.response) throw error; + + return rejectWithValue( + `Error: Request failed with status code ${error.response.status}`, + ); + } +}); diff --git a/verification/curator-service/ui/src/redux/store.ts b/verification/curator-service/ui/src/redux/store.ts index 277578bac..d0f7e39bc 100644 --- a/verification/curator-service/ui/src/redux/store.ts +++ b/verification/curator-service/ui/src/redux/store.ts @@ -5,6 +5,7 @@ import authReducer from './auth/slice'; import filtersReducer from './filters/slice'; import acknowledgmentDataReducer from './acknowledgmentData/slice'; import linelistTableReducer from './linelistTable/slice'; +import bundledCasesTableReducer from './bundledCases/slice'; import pivotTablesReducer from './pivotTables/slice'; import { SortBy, SortByOrder } from '../constants/types'; import validateEnv from '../util/validate-env'; @@ -18,6 +19,7 @@ export const rootReducer = combineReducers({ filters: filtersReducer, acknowledgment: acknowledgmentDataReducer, linelist: linelistTableReducer, + bundledCases: bundledCasesTableReducer, pivotTables: pivotTablesReducer, }); @@ -86,6 +88,27 @@ export const initialLoggedInState: RootState = { verificationStatus: undefined, rowsAcrossPagesSelected: false, }, + bundledCases: { + isLoading: false, + cases: [], + currentPage: 0, + nextPage: 1, + rowsPerPage: 50, + sort: { + value: SortBy.Identifier, + order: SortByOrder.Descending, + }, + searchQuery: '', + total: 0, + error: undefined, + excludeCasesDialogOpen: false, + deleteCasesDialogOpen: false, + reincludeCasesDialogOpen: false, + casesSelected: [], + refetchData: false, + verificationStatus: undefined, + rowsAcrossPagesSelected: false, + }, pivotTables: { isLoading: false, casesByCountries: [], diff --git a/verification/curator-service/ui/src/theme/theme.ts b/verification/curator-service/ui/src/theme/theme.ts index 5547e8c90..2b52941a5 100644 --- a/verification/curator-service/ui/src/theme/theme.ts +++ b/verification/curator-service/ui/src/theme/theme.ts @@ -53,6 +53,24 @@ declare module '@mui/material/styles' { } } +// define custom colors: https://material-ui.com/customization/palette/ +declare module '@mui/material/styles/createPalette' { + interface Palette { + light: Palette['primary']; + + } + interface PaletteOptions { + light: PaletteOptions['primary']; + } +} + +// Extend color prop on components +declare module '@mui/material/Button' { + export interface ButtonPropsColorOverrides { + light: true + } +} + export const theme = createTheme({ palette: { background: { @@ -71,6 +89,10 @@ export const theme = createTheme({ main: '#FD685B', contrastText: '#454545', }, + light: { + main: '#FFFFFF', + contrastText: '#0E7569', + } }, typography: { fontFamily: 'Inter, sans-serif',