From 01a90ab5bcdb7919a2f9d69ca3d82b448e5cd724 Mon Sep 17 00:00:00 2001 From: Courey Elliott Date: Wed, 14 Aug 2024 10:21:12 -0400 Subject: [PATCH] adds synonyms moderation front end adds validation and error handling fixes tests adds modal warning prior to delete changes removal button to words instead of only icons code review change requests removing unused className formatting autofill moderator synonym eventId selector adding create sad path test removing feature flag check adding a 3 second debounce --- __tests__/synonyms.server.ts | 162 +++++++++++- .../pagination/GCNPagination.tsx} | 2 +- .../pagination/PaginationSelectionFooter.tsx | 61 +++++ .../circulars._archive._index/route.tsx | 52 +--- app/routes/synonyms.$synonymId.tsx | 245 ++++++++++++------ app/routes/synonyms._index.tsx | 122 +++++++++ app/routes/synonyms.new.tsx | 155 +++++++++++ app/routes/synonyms/synonyms.server.ts | 130 ++++++++-- 8 files changed, 772 insertions(+), 157 deletions(-) rename app/{routes/circulars._archive._index/CircularPagination.tsx => components/pagination/GCNPagination.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/GCNPagination.tsx similarity index 99% rename from app/routes/circulars._archive._index/CircularPagination.tsx rename to app/components/pagination/GCNPagination.tsx index 664ed3763..87b4dd0d3 100644 --- a/app/routes/circulars._archive._index/CircularPagination.tsx +++ b/app/components/pagination/GCNPagination.tsx @@ -35,7 +35,7 @@ function getPageLink({ return searchString && `?${searchString}` } -export default function ({ +export default function GCNPagination({ page, totalPages, ...queryStringProps diff --git a/app/components/pagination/PaginationSelectionFooter.tsx b/app/components/pagination/PaginationSelectionFooter.tsx new file mode 100644 index 000000000..d3550645c --- /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 GCNPagination from './GCNPagination' + +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..9bdfc0df2 100644 --- a/app/routes/synonyms.$synonymId.tsx +++ b/app/routes/synonyms.$synonymId.tsx @@ -7,25 +7,62 @@ */ 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, } } @@ -61,81 +98,103 @@ export async function action({ } export default function () { - const { eventIds } = useLoaderData() + const { eventIds, options } = useLoaderData() + const uniqueOptions = Array.from(new Set(options)) as string[] const [deleteSynonyms, setDeleteSynonyms] = useState([] as string[]) const [synonyms, setSynonyms] = useState(eventIds || []) const [addSynonyms, setAddSynonyms] = useState([] as string[]) - const [newSynonym, setNewSynonym] = useState('') + const uniqueSynonyms = Array.from(new Set(synonyms)) as string[] + 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}
{ @@ -148,7 +207,7 @@ export default function () {
    - {synonyms?.map((synonym) => ( + {uniqueSynonyms?.map((synonym) => (
  • {synonym} @@ -169,7 +228,7 @@ export default function () { ) }} > - + Remove
  • @@ -185,6 +244,44 @@ export default function () { + + + Are you sure you want to continue? + +
    + +
    + + +
    + + +
    + + Go back + +
    +
    +
    ) } diff --git a/app/routes/synonyms._index.tsx b/app/routes/synonyms._index.tsx new file mode 100644 index 000000000..dd1c01959 --- /dev/null +++ b/app/routes/synonyms._index.tsx @@ -0,0 +1,122 @@ +/*! + * 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 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: { url } }: LoaderFunctionArgs) { + const { searchParams } = new URL(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/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 } +}