diff --git a/.github/ISSUE_TEMPLATE/producer-onboarding.md b/.github/ISSUE_TEMPLATE/producer-onboarding.md
new file mode 100644
index 000000000..4c84d5a05
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/producer-onboarding.md
@@ -0,0 +1,27 @@
+---
+name: Add New GCN Notice Producer
+about: Checklist to add a new producer of GCN Notices
+labels: new-producer
+---
+
+
+
+# Description
+
+Steps for onboarding new notices from the #### mission/observatory/instrument.
+Documentation: https://gcn.nasa.gov/docs/notices/producers
+Unified Schema: https://gcn.nasa.gov/docs/notices/schema
+
+# Acceptance criteria
+
+- [ ] create topics/acls
+- [ ] JSON schema
+- [ ] mission page
+- [ ] add to quickstart
+- [ ] announcement
+
+# Mission contact people
diff --git a/.npmrc b/.npmrc
new file mode 100644
index 000000000..fd5537201
--- /dev/null
+++ b/.npmrc
@@ -0,0 +1 @@
+prefer-dedupe=true
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/components/NoticeTypeCheckboxes/NoticeTypeCheckboxes.tsx b/app/components/NoticeTypeCheckboxes/NoticeTypeCheckboxes.tsx
index de9d8ac1f..4cd1cea09 100644
--- a/app/components/NoticeTypeCheckboxes/NoticeTypeCheckboxes.tsx
+++ b/app/components/NoticeTypeCheckboxes/NoticeTypeCheckboxes.tsx
@@ -231,6 +231,17 @@ export function NoticeTypeCheckboxes({
JsonNoticeTypeLinks.SVOM = '/missions/svom'
}
+ if (useFeature('FERMI_GBM_QUICKSTART')) {
+ JsonNoticeTypes.Fermi = [
+ 'gcn.notices.fermi.gbm.alert',
+ 'gcn.notices.fermi.gbm.final_position',
+ 'gcn.notices.fermi.gbm.flight_position',
+ 'gcn.notices.fermi.gbm.ground_position',
+ 'gcn.notices.fermi.gbm.subthreshold',
+ ]
+ JsonNoticeTypeLinks.Fermi = '/missions/fermi'
+ }
+
const counterfunction = (childRef: HTMLInputElement) => {
if (childRef.checked) {
userSelected.add(childRef.name)
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/lib/kafka.server.ts b/app/lib/kafka.server.ts
index dcaa60ba0..eb85f2fa5 100644
--- a/app/lib/kafka.server.ts
+++ b/app/lib/kafka.server.ts
@@ -8,10 +8,10 @@
import { Kafka } from 'gcn-kafka'
import memoizee from 'memoizee'
-import { domain, getEnvOrDie } from './env.server'
+import { domain, getEnvOrDieInProduction } from './env.server'
-const client_id = getEnvOrDie('KAFKA_CLIENT_ID')
-const client_secret = getEnvOrDie('KAFKA_CLIENT_SECRET')
+const client_id = getEnvOrDieInProduction('KAFKA_CLIENT_ID') ?? ''
+const client_secret = getEnvOrDieInProduction('KAFKA_CLIENT_SECRET')
const kafka = new Kafka({
client_id,
client_secret,
diff --git a/app/lib/zendesk.server.ts b/app/lib/zendesk.server.ts
index 65fb2d1bd..a806f1173 100644
--- a/app/lib/zendesk.server.ts
+++ b/app/lib/zendesk.server.ts
@@ -43,6 +43,42 @@ export async function postZendeskRequest(request: ZendeskRequest) {
if (!response.ok) {
console.error(response)
- throw new Error(`Reqeust failed with status ${response.status}`)
+ throw new Error(`Request failed with status ${response.status}`)
+ }
+
+ const responseJson = await response.json()
+ if (!responseJson.request.id) {
+ console.error(responseJson)
+ throw new Error(
+ 'ZenDesk request succeeded, but did not return a request ID'
+ )
+ }
+
+ return responseJson.request.id
+}
+
+export async function closeZendeskTicket(ticketId: number) {
+ const response = await fetch(
+ `https://nasa-gcn.zendesk.com/api/v2/tickets/${ticketId}.json`,
+ {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json',
+ ...getBasicAuthHeaders(
+ `${getEnvOrDie('ZENDESK_TOKEN_EMAIL')}/token`,
+ getEnvOrDie('ZENDESK_TOKEN')
+ ),
+ },
+ body: JSON.stringify({
+ ticket: {
+ status: 'solved',
+ },
+ }),
+ }
+ )
+
+ if (!response.ok) {
+ console.error(response)
+ throw new Error(`Request failed with status ${response.status}`)
}
}
diff --git a/app/root.tsx b/app/root.tsx
index 10ac78d99..47e2a5685 100644
--- a/app/root.tsx
+++ b/app/root.tsx
@@ -253,10 +253,10 @@ export function Layout({ children }: { children?: ReactNode }) {
- New! Circulars over Kafka, Heartbeat Topic, and Schema v4.1.0. See{' '}
+ New! October 18 GCN Classic Outage and Schema v4.2.0. See{' '}
news and announcements
diff --git a/app/routes/circulars._archive._index/route.tsx b/app/routes/circulars._archive._index/route.tsx
index 76a55d40e..005443b3a 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'
@@ -34,19 +27,20 @@ import {
circularRedirect,
createChangeRequest,
get,
+ getChangeRequest,
getChangeRequests,
moderatorGroup,
put,
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'
@@ -110,22 +104,39 @@ export async function action({ request }: ActionFunctionArgs) {
if (!createdOnDate || !createdOn)
throw new Response(null, { status: 400 })
+
+ let zendeskTicketId: number | undefined
+
+ try {
+ zendeskTicketId = (
+ await getChangeRequest(parseFloat(circularId), user.sub)
+ ).zendeskTicketId
+ } catch (err) {
+ if (!(err instanceof Response && err.status === 404)) throw err
+ }
+
+ if (!zendeskTicketId) {
+ zendeskTicketId = await postZendeskRequest({
+ requester: { name: user.name, email: user.email },
+ subject: `Change Request for Circular ${circularId}`,
+ comment: {
+ body: `${user.name} has requested an edit. Review at ${origin}/circulars`,
+ },
+ })
+ }
+
+ if (!zendeskTicketId) throw new Response(null, { status: 500 })
+
await createChangeRequest(
{
circularId: parseFloat(circularId),
...props,
submitter,
createdOn,
+ zendeskTicketId,
},
user
)
- await postZendeskRequest({
- requester: { name: user.name, email: user.email },
- subject: `Change Request for Circular ${circularId}`,
- comment: {
- body: `${user.name} has requested an edit. Review at ${origin}/circulars`,
- },
- })
newCircular = null
break
case 'edit':
@@ -205,6 +216,11 @@ export default function () {
{requestedChangeCount > 1 ? 's' : ''}
)}
+ {userIsModerator && (
+
+ Synonym Moderation
+
+ )}
-
-
-
-
-
-
-
- {totalPages > 1 && (
-
- )}
-
-
+
>
)}
>
diff --git a/app/routes/circulars.correction.$circularId.tsx b/app/routes/circulars.correction.$circularId.tsx
index c4979c2a1..77bd6765a 100644
--- a/app/routes/circulars.correction.$circularId.tsx
+++ b/app/routes/circulars.correction.$circularId.tsx
@@ -12,7 +12,11 @@ import { useLoaderData } from '@remix-run/react'
import { getUser } from './_auth/user.server'
import { CircularEditForm } from './circulars.edit.$circularId/CircularEditForm'
import { formatAuthor } from './circulars/circulars.lib'
-import { get, submitterGroup } from './circulars/circulars.server'
+import {
+ get,
+ getChangeRequest,
+ submitterGroup,
+} from './circulars/circulars.server'
import type { BreadcrumbHandle } from '~/root/Title'
export const handle: BreadcrumbHandle & SEOHandle = {
@@ -29,7 +33,13 @@ export async function loader({
const user = await getUser(request)
if (!user?.groups.includes(submitterGroup))
throw new Response(null, { status: 403 })
- const circular = await get(parseFloat(circularId))
+ let existingRequest
+ try {
+ existingRequest = await getChangeRequest(parseFloat(circularId), user.sub)
+ } catch (err) {
+ if (!(err instanceof Response && err.status === 404)) throw err
+ }
+ const circular = existingRequest ?? (await get(parseFloat(circularId)))
const defaultDateTime = new Date(circular.createdOn ?? 0).toISOString()
return {
diff --git a/app/routes/circulars.moderation.$circularId.$requestor.tsx b/app/routes/circulars.moderation.$circularId.$requestor.tsx
index 88a779f47..f5bdf846c 100644
--- a/app/routes/circulars.moderation.$circularId.$requestor.tsx
+++ b/app/routes/circulars.moderation.$circularId.$requestor.tsx
@@ -8,7 +8,7 @@
import type { SEOHandle } from '@nasa-gcn/remix-seo'
import type { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/node'
import { Form, redirect, useLoaderData } from '@remix-run/react'
-import { Button, ButtonGroup } from '@trussworks/react-uswds'
+import { Button, ButtonGroup, Checkbox } from '@trussworks/react-uswds'
import { diffLines, diffWords } from 'diff'
import { getUser } from './_auth/user.server'
@@ -41,11 +41,18 @@ export async function action({
throw new Response(null, { status: 403 })
const data = await request.formData()
const intent = getFormDataString(data, 'intent')
+ const redistribute = getFormDataString(data, 'redistribute') === 'on'
+
if (!intent || !circularId || !requestor)
throw new Response(null, { status: 400 })
switch (intent) {
case 'approve':
- await approveChangeRequest(parseFloat(circularId), requestor, user)
+ await approveChangeRequest(
+ parseFloat(circularId),
+ requestor,
+ user,
+ redistribute
+ )
break
case 'reject':
await deleteChangeRequest(parseFloat(circularId), requestor, user)
@@ -100,6 +107,13 @@ export default function () {
Body
+
+
+ Are you sure you want to continue?
+
+
+
+ You are about to permanently delete this Synonym Group.
+
+
+
+
+
+
+ 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}
+
+ >
+ )
+}
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 }
+}
diff --git a/app/routes/user.email._index/EmailNotificationCard.tsx b/app/routes/user.email._index/EmailNotificationCard.tsx
index be0005424..070194c32 100644
--- a/app/routes/user.email._index/EmailNotificationCard.tsx
+++ b/app/routes/user.email._index/EmailNotificationCard.tsx
@@ -2,6 +2,7 @@ import { Form, useFetcher } from '@remix-run/react'
import type { ModalRef } from '@trussworks/react-uswds'
import {
Button,
+ ButtonGroup,
Grid,
Icon,
Modal,
@@ -9,11 +10,13 @@ import {
ModalHeading,
ModalToggleButton,
} from '@trussworks/react-uswds'
-import { useEffect, useRef } from 'react'
+import { useEffect, useRef, useState } from 'react'
import type { EmailNotificationVM } from '../user.email/email_notices.server'
+import { ReCAPTCHA } from '~/components/ReCAPTCHA'
import TimeAgo from '~/components/TimeAgo'
import { ToolbarButtonGroup } from '~/components/ToolbarButtonGroup'
+import { useRecaptchaSiteKey } from '~/root'
export default function EmailNotificationCard({
uuid,
@@ -25,9 +28,11 @@ export default function EmailNotificationCard({
}: EmailNotificationVM) {
const deleteModalRef = useRef(null)
const testModalRef = useRef(null)
+ const testConfirmRef = useRef(null)
const deleteFetcher = useFetcher()
const testFetcher = useFetcher()
const disabled = deleteFetcher.state !== 'idle'
+ const [recaptchaValid, setRecaptchaValid] = useState(!useRecaptchaSiteKey())
useEffect(() => {
if (
@@ -36,6 +41,8 @@ export default function EmailNotificationCard({
testModalRef.current
) {
testModalRef.current.toggleModal(undefined, true)
+ setRecaptchaValid(false)
+ grecaptcha.reset()
}
}, [testFetcher.state, testFetcher.data, testModalRef])
@@ -61,17 +68,15 @@ export default function EmailNotificationCard({
-
-
-
-
-
+
+ Test Message
+