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 (
+
+
+
+ {
+ submit(form)
+ }}
+ >
+ 10 / page
+ 20 / page
+ 50 / page
+ 100 / page
+
+
+
+
+ {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}
/>
-
-
-
- {
- submit(form)
- }}
- >
- 10 / page
- 20 / page
- 50 / page
- 100 / page
-
-
-
-
- {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.
-
+
+
+ {
+ 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}
+
+
+ Are you sure you want to continue?
+
+
+
+ You are about to permanently delete this Synonym Group.
+
+
+
+
+
+
+ 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
+
+
+
+
+
+
+ New
+
+
+
+
+
+
+
+ >
+ )
+}
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}
+
+ >
+ )
+}
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 }
+}