From 14f8268228e97bcc5afb6392a383f7128027b7e2 Mon Sep 17 00:00:00 2001 From: Courey Elliott Date: Tue, 15 Oct 2024 22:50:56 -0400 Subject: [PATCH] adds synonyms moderation front end (#2538) Resolves #2116. --- __tests__/synonyms.server.ts | 162 +++++++++- .../pagination/Pagination.tsx} | 2 +- .../pagination/PaginationSelectionFooter.tsx | 61 ++++ .../circulars._archive._index/route.tsx | 52 +--- app/routes/synonyms.$synonymId.tsx | 286 ++++++++++++------ app/routes/synonyms._index.tsx | 129 ++++++++ app/routes/synonyms.new.tsx | 155 ++++++++++ app/routes/synonyms/route.tsx | 9 +- app/routes/synonyms/synonyms.server.ts | 130 ++++++-- 9 files changed, 810 insertions(+), 176 deletions(-) rename app/{routes/circulars._archive._index/CircularPagination.tsx => components/pagination/Pagination.tsx} (99%) create mode 100644 app/components/pagination/PaginationSelectionFooter.tsx create mode 100644 app/routes/synonyms._index.tsx create mode 100644 app/routes/synonyms.new.tsx diff --git a/__tests__/synonyms.server.ts b/__tests__/synonyms.server.ts index 088b8f608..84f020558 100644 --- a/__tests__/synonyms.server.ts +++ b/__tests__/synonyms.server.ts @@ -3,28 +3,65 @@ import type { AWSError, DynamoDB } from 'aws-sdk' import * as awsSDKMock from 'aws-sdk-mock' import crypto from 'crypto' +import type { Circular } from '~/routes/circulars/circulars.lib' import { createSynonyms, putSynonyms } from '~/routes/synonyms/synonyms.server' jest.mock('@architect/functions') const synonymId = 'abcde-abcde-abcde-abcde-abcde' +const exampleCirculars = [ + { + Items: [ + { + circularId: 1234556, + subject: 'subject 1', + body: 'very intelligent things', + eventId: 'eventId1', + createdOn: 12345567, + submitter: 'steve', + } as Circular, + ], + }, + { + Items: [ + { + circularId: 1230000, + subject: 'subject 2', + body: 'more intelligent things', + eventId: 'eventId2', + createdOn: 12345560, + submitter: 'steve', + } as Circular, + ], + }, + { Items: [] }, +] describe('createSynonyms', () => { - beforeAll(() => { + beforeEach(() => { const mockBatchWrite = jest.fn() + const mockQuery = jest.fn() + const mockClient = { batchWrite: mockBatchWrite, + query: mockQuery, } - ;(tables as unknown as jest.Mock).mockResolvedValue({ + + ;(tables as unknown as jest.Mock).mockReturnValue({ _doc: mockClient, name: () => { return 'synonyms' }, + circulars: { + query: mockQuery + .mockReturnValueOnce(exampleCirculars[0]) + .mockReturnValueOnce(exampleCirculars[1]), + }, }) jest.spyOn(crypto, 'randomUUID').mockReturnValue(synonymId) }) - afterAll(() => { + afterEach(() => { jest.restoreAllMocks() }) @@ -48,12 +85,51 @@ describe('createSynonyms', () => { expect(result).toBe(synonymId) }) + + test('createSynonyms with nonexistent eventId throws Response 400', async () => { + const mockBatchWriteItem = jest.fn( + ( + params: DynamoDB.DocumentClient.BatchWriteItemInput, + callback: ( + err: AWSError | null, + data?: DynamoDB.DocumentClient.BatchWriteItemOutput + ) => void + ) => { + expect(params.RequestItems.synonyms).toBeDefined() + callback(null, {}) + } + ) + awsSDKMock.mock('DynamoDB', 'batchWriteItem', mockBatchWriteItem) + + const synonymousEventIds = ['eventId1', 'nope'] + try { + await createSynonyms(synonymousEventIds) + } catch (error) { + // eslint-disable-next-line jest/no-conditional-expect + expect(error).toBeInstanceOf(Response) + const convertedError = error as Response + // eslint-disable-next-line jest/no-conditional-expect + expect(convertedError.status).toBe(400) + const errorMessage = await convertedError.text() + // eslint-disable-next-line jest/no-conditional-expect + expect(errorMessage).toBe('eventId does not exist') + } + }) }) describe('putSynonyms', () => { const mockBatchWrite = jest.fn() + const mockQuery = jest.fn() beforeAll(() => { + jest.spyOn(crypto, 'randomUUID').mockReturnValue(synonymId) + }) + + afterAll(() => { + jest.restoreAllMocks() + awsSDKMock.restore('DynamoDB') + }) + test('putSynonyms should not write to DynamoDB if no additions or subtractions', async () => { const mockClient = { batchWrite: mockBatchWrite, } @@ -63,15 +139,6 @@ describe('putSynonyms', () => { return 'synonyms' }, }) - - jest.spyOn(crypto, 'randomUUID').mockReturnValue(synonymId) - }) - - afterAll(() => { - jest.restoreAllMocks() - awsSDKMock.restore('DynamoDB') - }) - test('putSynonyms should not write to DynamoDB if no additions or subtractions', async () => { awsSDKMock.mock('DynamoDB.DocumentClient', 'batchWrite', mockBatchWrite) await putSynonyms({ synonymId }) @@ -79,7 +146,53 @@ describe('putSynonyms', () => { expect(mockBatchWrite).not.toHaveBeenCalled() }) + test('putSynonyms should throw 400 response if there are invalid additions', async () => { + const mockClient = { + batchWrite: mockBatchWrite, + query: mockQuery, + } + + ;(tables as unknown as jest.Mock).mockReturnValue({ + _doc: mockClient, + name: () => { + return 'synonyms' + }, + circulars: { + query: mockQuery.mockReturnValueOnce(exampleCirculars[2]), + }, + }) + awsSDKMock.mock('DynamoDB.DocumentClient', 'batchWrite', mockBatchWrite) + try { + await putSynonyms({ synonymId, additions: ["doesn't exist"] }) + } catch (error) { + // eslint-disable-next-line jest/no-conditional-expect + expect(error).toBeInstanceOf(Response) + const convertedError = error as Response + // eslint-disable-next-line jest/no-conditional-expect + expect(convertedError.status).toBe(400) + const errorMessage = await convertedError.text() + // eslint-disable-next-line jest/no-conditional-expect + expect(errorMessage).toBe('eventId does not exist') + } + }) + test('putSynonyms should write to DynamoDB if there are additions', async () => { + const mockClient = { + batchWrite: mockBatchWrite, + query: mockQuery, + } + + ;(tables as unknown as jest.Mock).mockReturnValue({ + _doc: mockClient, + name: () => { + return 'synonyms' + }, + circulars: { + query: mockQuery + .mockReturnValueOnce(exampleCirculars[0]) + .mockReturnValueOnce(exampleCirculars[1]), + }, + }) awsSDKMock.mock('DynamoDB.DocumentClient', 'batchWrite', mockBatchWrite) const additions = ['eventId1', 'eventId2'] await putSynonyms({ synonymId, additions }) @@ -109,6 +222,15 @@ describe('putSynonyms', () => { }) test('putSynonyms should write to DynamoDB if there are subtractions', async () => { + const mockClient = { + batchWrite: mockBatchWrite, + } + ;(tables as unknown as jest.Mock).mockResolvedValue({ + _doc: mockClient, + name: () => { + return 'synonyms' + }, + }) awsSDKMock.mock('DynamoDB.DocumentClient', 'batchWrite', mockBatchWrite) const subtractions = ['eventId3', 'eventId4'] @@ -126,6 +248,22 @@ describe('putSynonyms', () => { }) test('putSynonyms should write to DynamoDB if there are additions and subtractions', async () => { + const mockClient = { + batchWrite: mockBatchWrite, + query: mockQuery, + } + + ;(tables as unknown as jest.Mock).mockReturnValue({ + _doc: mockClient, + name: () => { + return 'synonyms' + }, + circulars: { + query: mockQuery + .mockReturnValueOnce(exampleCirculars[0]) + .mockReturnValueOnce(exampleCirculars[1]), + }, + }) awsSDKMock.mock('DynamoDB.DocumentClient', 'batchWrite', mockBatchWrite) const additions = ['eventId1', 'eventId2'] diff --git a/app/routes/circulars._archive._index/CircularPagination.tsx b/app/components/pagination/Pagination.tsx similarity index 99% rename from app/routes/circulars._archive._index/CircularPagination.tsx rename to app/components/pagination/Pagination.tsx index 664ed3763..481b92312 100644 --- a/app/routes/circulars._archive._index/CircularPagination.tsx +++ b/app/components/pagination/Pagination.tsx @@ -35,7 +35,7 @@ function getPageLink({ return searchString && `?${searchString}` } -export default function ({ +export default function Pagination({ page, totalPages, ...queryStringProps diff --git a/app/components/pagination/PaginationSelectionFooter.tsx b/app/components/pagination/PaginationSelectionFooter.tsx new file mode 100644 index 000000000..f53af9195 --- /dev/null +++ b/app/components/pagination/PaginationSelectionFooter.tsx @@ -0,0 +1,61 @@ +/*! + * Copyright © 2023 United States Government as represented by the + * Administrator of the National Aeronautics and Space Administration. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + */ +import { useSubmit } from '@remix-run/react' +import { Select } from '@trussworks/react-uswds' + +import Pagination from './Pagination' + +export default function PaginationSelectionFooter({ + page, + totalPages, + limit, + query, + form, +}: { + page: number + totalPages: number + limit?: number + query?: string + form: string +}) { + const submit = useSubmit() + return ( +
+
+
+ +
+
+
+ {totalPages > 1 && ( + + )} +
+
+ ) +} diff --git a/app/routes/circulars._archive._index/route.tsx b/app/routes/circulars._archive._index/route.tsx index 27b270a54..30d07a811 100644 --- a/app/routes/circulars._archive._index/route.tsx +++ b/app/routes/circulars._archive._index/route.tsx @@ -14,14 +14,7 @@ import { useSearchParams, useSubmit, } from '@remix-run/react' -import { - Alert, - Button, - Icon, - Label, - Select, - TextInput, -} from '@trussworks/react-uswds' +import { Alert, Button, Icon, Label, TextInput } from '@trussworks/react-uswds' import clamp from 'lodash/clamp' import { useId, useState } from 'react' @@ -41,13 +34,13 @@ import { putVersion, search, } from '../circulars/circulars.server' -import CircularPagination from './CircularPagination' import CircularsHeader from './CircularsHeader' import CircularsIndex from './CircularsIndex' import { DateSelector } from './DateSelectorMenu' import { SortSelector } from './SortSelectorButton' import Hint from '~/components/Hint' import { ToolbarButtonGroup } from '~/components/ToolbarButtonGroup' +import PaginationSelectionFooter from '~/components/pagination/PaginationSelectionFooter' import { origin } from '~/lib/env.server' import { getFormDataString } from '~/lib/utils' import { postZendeskRequest } from '~/lib/zendesk.server' @@ -281,40 +274,13 @@ export default function () { totalItems={totalItems} query={query} /> -
-
-
- -
-
-
- {totalPages > 1 && ( - - )} -
-
+ )} diff --git a/app/routes/synonyms.$synonymId.tsx b/app/routes/synonyms.$synonymId.tsx index 98b688562..e6e902390 100644 --- a/app/routes/synonyms.$synonymId.tsx +++ b/app/routes/synonyms.$synonymId.tsx @@ -7,25 +7,63 @@ */ import type { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/node' import { redirect } from '@remix-run/node' -import { Form, Link, useLoaderData } from '@remix-run/react' -import { Button, ButtonGroup, FormGroup, Icon } from '@trussworks/react-uswds' -import { useState } from 'react' +import { + Form, + Link, + useFetcher, + useLoaderData, + useSubmit, +} from '@remix-run/react' +import type { ModalRef } from '@trussworks/react-uswds' +import { + Button, + ButtonGroup, + CardBody, + FormGroup, + Grid, + Icon, + Modal, + ModalFooter, + ModalHeading, + ModalToggleButton, + TextInput, +} from '@trussworks/react-uswds' +import { useEffect, useRef, useState } from 'react' import invariant from 'tiny-invariant' +import { useOnClickOutside } from 'usehooks-ts' +import { getUser } from './_auth/user.server' +import { moderatorGroup } from './circulars/circulars.server' import { + autoCompleteEventIds, deleteSynonyms, getSynonymsByUuid, putSynonyms, } from './synonyms/synonyms.server' +import DetailsDropdownContent from '~/components/DetailsDropdownContent' +import { ToolbarButtonGroup } from '~/components/ToolbarButtonGroup' import { getFormDataString } from '~/lib/utils' -export async function loader({ params: { synonymId } }: LoaderFunctionArgs) { +export async function loader({ + request, + params: { synonymId }, +}: LoaderFunctionArgs) { + const user = await getUser(request) + if (!user?.groups.includes(moderatorGroup)) + throw new Response(null, { status: 403 }) + invariant(synonymId) const synonyms = await getSynonymsByUuid(synonymId) const eventIds = synonyms.map((synonym) => synonym.eventId) + const url = new URL(request.url) + const query = url.searchParams.get('query') + const { options } = query + ? await autoCompleteEventIds({ query }) + : { options: [] } return { eventIds, + options, } } @@ -38,17 +76,15 @@ export async function action({ const intent = getFormDataString(data, 'intent') if (intent === 'edit') { - const additions = - getFormDataString(data, 'addSynonyms')?.split(',') || ([] as string[]) - const filtered_additions = additions.filter((add) => add) - const subtractions = - getFormDataString(data, 'deleteSynonyms')?.split(',') || ([] as string[]) - const filtered_subtractions = subtractions.filter((sub) => sub) + const additions = data.getAll('addSynonyms') as string[] + const subtractions = data.getAll('deleteSynonyms') as string[] + await putSynonyms({ synonymId, - additions: filtered_additions, - subtractions: filtered_subtractions, + additions, + subtractions, }) + return null } else if (intent === 'delete') { await deleteSynonyms(synonymId) @@ -61,81 +97,103 @@ export async function action({ } export default function () { - const { eventIds } = useLoaderData() - const [deleteSynonyms, setDeleteSynonyms] = useState([] as string[]) - const [synonyms, setSynonyms] = useState(eventIds || []) - const [addSynonyms, setAddSynonyms] = useState([] as string[]) - const [newSynonym, setNewSynonym] = useState('') + const { eventIds, options } = useLoaderData() + const uniqueOptions = Array.from(new Set(options)) + const [deleteSynonyms, setDeleteSynonyms] = useState([]) + const [synonyms, setSynonyms] = useState(eventIds || []) + const [addSynonyms, setAddSynonyms] = useState([]) + const uniqueSynonyms = Array.from(new Set(synonyms)) + const [input, setInput] = useState('') + const modalRef = useRef(null) + const ref = useRef(null) + const fetcher = useFetcher() + const submit = useSubmit() + + const [showContent, setShowContent] = useState(false) + useOnClickOutside(ref, () => { + setShowContent(false) + }) + + useEffect(() => { + const delayDebounceFn = setTimeout(() => { + if (input.length >= 3) + submit({ query: input }, { preventScrollReset: true }) + }, 3000) + + return () => clearTimeout(delayDebounceFn) + }, [input, submit]) return ( <> - -

Synonym Group

- - -
- -
- -
- - -
-
-
+ + +
+ +
+       Back + + + {' '} + Delete + +
+

Synonym Group

- Synonym groupings are limited to 25 synonymous event identifiers. If you - are adding an event identifier that is already part of a group, it will - be removed from the previous association and added to this group. + If you are adding an event identifier that is already part of a group, + it will be removed from the previous association and added to this + group.

-
- - - { - setNewSynonym(e.currentTarget.value) - }} - /> - - - -
+ + + { + setInput(value) + setShowContent(true) + if (value.length >= 3) submit(form, { preventScrollReset: true }) + }} + id="query-input" + /> + + + {uniqueOptions.length && input && showContent ? ( +
+ + + {uniqueOptions.map((eventId: string) => ( +
{ + setSynonyms([...synonyms, eventId]) + setAddSynonyms( + Array.from(new Set([...addSynonyms, eventId])) + ) + setDeleteSynonyms( + deleteSynonyms.filter(function (item) { + return item !== eventId + }) + ) + setInput('') + }} + > + {eventId} +
+ ))} +
+
+
+ ) : null}
{ @@ -145,10 +203,26 @@ export default function () { > - - + {addSynonyms.map((synonym) => ( + + ))} + {deleteSynonyms.map((synonym) => ( + + ))}
    - {synonyms?.map((synonym) => ( + {uniqueSynonyms?.map((synonym) => (
  • {synonym} @@ -163,13 +237,13 @@ export default function () { }) ) setAddSynonyms( - synonyms.filter(function (item) { + addSynonyms.filter(function (item) { return item !== synonym }) ) }} > - + Remove
  • @@ -185,6 +259,44 @@ export default function () { + + + Are you sure you want to continue? + +
    + +
    + + +
    + + +
    + + Cancel + +
    +
    +
    ) } diff --git a/app/routes/synonyms._index.tsx b/app/routes/synonyms._index.tsx new file mode 100644 index 000000000..065dbc11c --- /dev/null +++ b/app/routes/synonyms._index.tsx @@ -0,0 +1,129 @@ +/*! + * Copyright © 2023 United States Government as represented by the + * Administrator of the National Aeronautics and Space Administration. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + */ +import type { LoaderFunctionArgs } from '@remix-run/node' +import { + Form, + Link, + useLoaderData, + useSearchParams, + useSubmit, +} from '@remix-run/react' +import { + Button, + Grid, + GridContainer, + Icon, + Label, + TextInput, +} from '@trussworks/react-uswds' +import { useId, useState } from 'react' + +import { getUser } from './_auth/user.server' +import { moderatorGroup } from './circulars/circulars.server' +import type { SynonymGroup } from './synonyms/synonyms.lib' +import { searchSynonymsByEventId } from './synonyms/synonyms.server' +import { ToolbarButtonGroup } from '~/components/ToolbarButtonGroup' +import PaginationSelectionFooter from '~/components/pagination/PaginationSelectionFooter' + +import searchImg from 'nasawds/src/img/usa-icons-bg/search--white.svg' + +export async function loader({ request }: LoaderFunctionArgs) { + const user = await getUser(request) + if (!user?.groups.includes(moderatorGroup)) + throw new Response(null, { status: 403 }) + + const { searchParams } = new URL(request.url) + const query = searchParams.get('query') || undefined + const limit = parseInt(searchParams.get('limit') || '100') + const page = parseInt(searchParams.get('page') || '1') + const synonyms = searchSynonymsByEventId({ page, eventId: query, limit }) + + return synonyms +} + +function SynonymList({ synonyms }: { synonyms: SynonymGroup[] }) { + return ( +
      + {synonyms.map((synonym) => { + return ( +
    • + + {synonym.eventIds.join(', ')} + +
    • + ) + })} +
    + ) +} +export default function () { + const { synonyms, page, totalPages } = useLoaderData() + const submit = useSubmit() + const formId = useId() + const [searchParams] = useSearchParams() + const limit = searchParams.get('limit') || '100' + const query = searchParams.get('query') || '' + + const [inputQuery, setInputQuery] = useState('') + + return ( + <> +

    Synonym Group Moderation

    + + + + + + + + + + ) +} diff --git a/app/routes/synonyms.new.tsx b/app/routes/synonyms.new.tsx new file mode 100644 index 000000000..665ce4770 --- /dev/null +++ b/app/routes/synonyms.new.tsx @@ -0,0 +1,155 @@ +/*! + * Copyright © 2023 United States Government as represented by the + * Administrator of the National Aeronautics and Space Administration. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + */ +import type { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/node' +import { + Form, + redirect, + useFetcher, + useLoaderData, + useSubmit, +} from '@remix-run/react' +import { + Button, + ButtonGroup, + CardBody, + FormGroup, + Grid, + TextInput, +} from '@trussworks/react-uswds' +import { useEffect, useRef, useState } from 'react' +import { useOnClickOutside } from 'usehooks-ts' + +import { getUser } from './_auth/user.server' +import { moderatorGroup } from './circulars/circulars.server' +import { + autoCompleteEventIds, + createSynonyms, +} from './synonyms/synonyms.server' +import DetailsDropdownContent from '~/components/DetailsDropdownContent' +import { getFormDataString } from '~/lib/utils' + +export async function action({ request }: ActionFunctionArgs) { + const user = await getUser(request) + if (!user?.groups.includes(moderatorGroup)) + throw new Response(null, { status: 403 }) + const data = await request.formData() + const eventIds = getFormDataString(data, 'synonyms')?.split(',') + if (!eventIds) throw new Response(null, { status: 400 }) + const synonymId = await createSynonyms(eventIds) + return redirect(`/synonyms/${synonymId}`) +} + +export async function loader(args: LoaderFunctionArgs) { + const url = new URL(args.request.url) + const query = url.searchParams.get('query') + if (query) { + return await autoCompleteEventIds({ query }) + } + return { options: [] } +} + +export default function () { + const ref = useRef(null) + const fetcher = useFetcher() + const submit = useSubmit() + + const [showContent, setShowContent] = useState(false) + useOnClickOutside(ref, () => { + setShowContent(false) + }) + const [synonyms, setSynonyms] = useState([] as string[]) + const uniqueSynonyms = Array.from(new Set(synonyms)) as string[] + const [input, setInput] = useState('') + + const { options } = useLoaderData() + const uniqueOptions = Array.from(new Set(options)) as string[] + + useEffect(() => { + const delayDebounceFn = setTimeout(() => { + if (input.length >= 3) + submit({ query: input }, { preventScrollReset: true }) + }, 3000) + + return () => clearTimeout(delayDebounceFn) + }, [input, submit]) + + return ( + <> +

    Create New Synonym Group

    + + + { + setInput(value) + setShowContent(true) + if (value.length >= 3) submit(form, { preventScrollReset: true }) + }} + id="query-input" + /> + + + {uniqueOptions.length && input && showContent ? ( +
    + + + {uniqueOptions.map((eventId: string) => ( +
    { + setSynonyms([...synonyms, eventId]) + setInput('') + }} + > + {eventId} +
    + ))} +
    +
    +
    + ) : null} +
    + + +
      + {uniqueSynonyms?.map((synonym) => ( +
    • + + {synonym} + + +
    • + ))} +
    +
    + + + +
    + + ) +} diff --git a/app/routes/synonyms/route.tsx b/app/routes/synonyms/route.tsx index 367b72028..d5887b6bd 100644 --- a/app/routes/synonyms/route.tsx +++ b/app/routes/synonyms/route.tsx @@ -10,16 +10,13 @@ import { Outlet } from '@remix-run/react' import { GridContainer } from '@trussworks/react-uswds' import { getUser } from '../_auth/user.server' +import { moderatorGroup } from '../circulars/circulars.server' export async function loader({ request }: LoaderFunctionArgs) { const user = await getUser(request) - const isModerator = user?.groups.includes('gcn.nasa.gov/circular-moderator') + if (!user?.groups.includes(moderatorGroup)) + throw new Response(null, { status: 403 }) - if (!isModerator) { - throw new Response(null, { - status: 403, - }) - } return null } diff --git a/app/routes/synonyms/synonyms.server.ts b/app/routes/synonyms/synonyms.server.ts index 684d30893..e38b388dd 100644 --- a/app/routes/synonyms/synonyms.server.ts +++ b/app/routes/synonyms/synonyms.server.ts @@ -10,7 +10,8 @@ import type { DynamoDBDocument } from '@aws-sdk/lib-dynamodb' import { search as getSearchClient } from '@nasa-gcn/architect-functions-search' import crypto from 'crypto' -import type { Synonym } from './synonyms.lib' +import type { Circular } from '../circulars/circulars.lib' +import type { Synonym, SynonymGroup } from './synonyms.lib' export async function getSynonymsByUuid(synonymId: string) { const db = await tables() @@ -34,7 +35,7 @@ export async function searchSynonymsByEventId({ page: number eventId?: string }): Promise<{ - synonyms: Record + synonyms: SynonymGroup[] totalItems: number totalPages: number page: number @@ -56,7 +57,7 @@ export async function searchSynonymsByEventId({ match: { eventIds: { query: eventId, - fuzziness: 'AUTO', + fuzziness: '1', }, }, }) @@ -79,18 +80,13 @@ export async function searchSynonymsByEventId({ }) const totalPages: number = Math.ceil(totalItems / limit) - const results: Record = {} - - hits.forEach( + const results = hits.map( ({ _source: body, }: { - _source: Synonym - fields: { eventId: string; synonymId: string } - }) => - results[body.synonymId] - ? results[body.synonymId].push(body.eventId) - : (results[body.synonymId] = [body.eventId]) + _source: SynonymGroup + fields: { eventIds: []; synonymId: string } + }) => body ) return { @@ -101,6 +97,31 @@ export async function searchSynonymsByEventId({ } } +async function validateEventIds({ eventIds }: { eventIds: string[] }) { + const promises = eventIds.map((eventId) => { + return getSynonymMembers(eventId) + }) + + const validityResponse = await Promise.all(promises) + const filteredResponses = validityResponse.filter((resp) => { + return resp.length + }) + + return filteredResponses.length === eventIds.length +} + +async function getSynonymMembers(eventId: string) { + const db = await tables() + const { Items } = await db.circulars.query({ + IndexName: 'circularsByEventId', + KeyConditionExpression: 'eventId = :eventId', + ExpressionAttributeValues: { + ':eventId': eventId, + }, + }) + return Items as Circular[] +} + /* * If an eventId already has a synonym and is passed in, it will unlink the * eventId from the old synonym and the only remaining link will be to the @@ -111,23 +132,24 @@ export async function searchSynonymsByEventId({ */ export async function createSynonyms(synonymousEventIds: string[]) { const uuid = crypto.randomUUID() - - if (synonymousEventIds.length > 0) { - const db = await tables() - const client = db._doc as unknown as DynamoDBDocument - const TableName = db.name('synonyms') - - await client.batchWrite({ - RequestItems: { - [TableName]: synonymousEventIds.map((eventId) => ({ - PutRequest: { - Item: { synonymId: uuid, eventId }, - }, - })), - }, - }) + if (!synonymousEventIds.length) { + throw new Response('EventIds are required.', { status: 400 }) } + const db = await tables() + const client = db._doc as unknown as DynamoDBDocument + const TableName = db.name('synonyms') + const isValid = await validateEventIds({ eventIds: synonymousEventIds }) + if (!isValid) throw new Response('eventId does not exist', { status: 400 }) + await client.batchWrite({ + RequestItems: { + [TableName]: synonymousEventIds.map((eventId) => ({ + PutRequest: { + Item: { synonymId: uuid, eventId }, + }, + })), + }, + }) return uuid } @@ -149,6 +171,10 @@ export async function putSynonyms({ subtractions?: string[] }) { if (!subtractions?.length && !additions?.length) return + if (additions?.length) { + const isValid = await validateEventIds({ eventIds: additions }) + if (!isValid) throw new Response('eventId does not exist', { status: 400 }) + } const db = await tables() const client = db._doc as unknown as DynamoDBDocument const TableName = db.name('synonyms') @@ -205,3 +231,53 @@ export async function deleteSynonyms(synonymId: string) { } await client.batchWrite(params) } + +export async function autoCompleteEventIds({ + query, +}: { + query: string +}): Promise<{ + options: string[] +}> { + const cleanedQuery = query.replace('-', ' ') + const client = await getSearchClient() + const { + body: { + hits: { hits }, + }, + } = await client.search({ + index: 'circulars', + body: { + query: { + bool: { + must: [ + { + query_string: { + query: `*${cleanedQuery}*`, + fields: ['eventId'], + fuzziness: 'AUTO', + }, + }, + ], + }, + }, + fields: ['eventId'], + _source: false, + from: 0, + size: 10, + track_total_hits: true, + }, + }) + const options = hits.map( + ({ + fields: { + eventId: [eventId], + }, + }: { + _id: string + fields: { eventId: string } + }) => eventId + ) + + return { options } +}