diff --git a/src/pages/Research/Content/Common/ResearchCategorySelect.tsx b/src/pages/Research/Content/Common/ResearchCategorySelect.tsx index 8144e2e736..2a986eb0e6 100644 --- a/src/pages/Research/Content/Common/ResearchCategorySelect.tsx +++ b/src/pages/Research/Content/Common/ResearchCategorySelect.tsx @@ -13,11 +13,7 @@ const ResearchFieldCategory = () => { useEffect(() => { const getCategories = async () => { const categories = await researchService.getResearchCategories() - setOptions( - categories - .filter((x) => !x._deleted) - .map((x) => ({ label: x.label, value: x })), - ) + setOptions(categories.map((x) => ({ label: x.label, value: x }))) } getCategories() diff --git a/src/pages/Research/Content/ResearchList.tsx b/src/pages/Research/Content/ResearchList.tsx index 303317e5c1..423037b25f 100644 --- a/src/pages/Research/Content/ResearchList.tsx +++ b/src/pages/Research/Content/ResearchList.tsx @@ -10,14 +10,13 @@ import DraftButton from 'src/pages/common/Drafts/DraftButton' import useDrafts from 'src/pages/common/Drafts/useDrafts' import { Box, Flex, Heading, useThemeUI } from 'theme-ui' -import { ITEMS_PER_PAGE, RESEARCH_EDITOR_ROLES } from '../constants' +import { RESEARCH_EDITOR_ROLES } from '../constants' import { listing } from '../labels' import { researchService } from '../research.service' import { ResearchFilterHeader } from './ResearchFilterHeader' import ResearchListItem from './ResearchListItem' import { ResearchSearchParams } from './ResearchSearchParams' -import type { DocumentData, QueryDocumentSnapshot } from 'firebase/firestore' import type { IResearch, ResearchStatus } from 'oa-shared' import type { ThemeWithName } from 'oa-themes' import type { ResearchSortOption } from '../ResearchSortOptions' @@ -34,9 +33,7 @@ const ResearchList = observer(() => { getDrafts: researchService.getDrafts, }) const [total, setTotal] = useState(0) - const [lastVisible, setLastVisible] = useState< - QueryDocumentSnapshot | undefined - >(undefined) + const [lastId, setLastId] = useState(undefined) const [searchParams, setSearchParams] = useSearchParams() const q = searchParams.get(ResearchSearchParams.q) || '' @@ -63,9 +60,7 @@ const ResearchList = observer(() => { } }, [q, category, status, sort]) - const fetchResearchItems = async ( - skipFrom?: QueryDocumentSnapshot, - ) => { + const fetchResearchItems = async (lastDocId?: string) => { setIsFetching(true) try { @@ -76,18 +71,17 @@ const ResearchList = observer(() => { category, sort, status, - skipFrom, - ITEMS_PER_PAGE, + lastDocId, ) - if (skipFrom) { + if (lastDocId) { // if skipFrom is set, means we are requesting another page that should be appended setResearchItems((items) => [...items, ...result.items]) } else { setResearchItems(result.items) } - setLastVisible(result.lastVisible) + setLastId(result.items[result.items.length - 1]._id) setTotal(result.total) } catch (error) { @@ -187,7 +181,7 @@ const ResearchList = observer(() => { > diff --git a/src/pages/Research/research.service.test.ts b/src/pages/Research/research.service.test.ts index 348edfcbd7..e576aa6a05 100644 --- a/src/pages/Research/research.service.test.ts +++ b/src/pages/Research/research.service.test.ts @@ -1,120 +1,109 @@ -import '@testing-library/jest-dom/vitest' - -import { ResearchStatus } from 'oa-shared' import { describe, expect, it, vi } from 'vitest' -import { exportedForTesting } from './research.service' - -const mockWhere = vi.fn() -const mockOrderBy = vi.fn() -const mockLimit = vi.fn() -vi.mock('firebase/firestore', () => ({ - collection: vi.fn(), - query: vi.fn(), - and: vi.fn(), - where: (path, op, value) => mockWhere(path, op, value), - limit: (limit) => mockLimit(limit), - orderBy: (field, direction) => mockOrderBy(field, direction), -})) - -vi.mock('../../stores/databaseV2/endpoints', () => ({ - DB_ENDPOINTS: { - research: 'research', - researchCategories: 'researchCategories', - }, -})) - -vi.mock('../../config/config', () => ({ - getConfigurationOption: vi.fn(), - FIREBASE_CONFIG: { - apiKey: 'AIyChVN', - databaseURL: 'https://test.firebaseio.com', - projectId: 'test', - storageBucket: 'test.appspot.com', - }, - localStorage: vi.fn(), - SITE: 'unit-tests', -})) - -describe('research.search', () => { - it('searches for text', () => { - // prepare - const words = ['test', 'text'] - - // act - exportedForTesting.createSearchQuery(words, '', 'MostRelevant', null) - - // assert - expect(mockWhere).toHaveBeenCalledWith( - 'keywords', - 'array-contains-any', - words, - ) +import { researchService } from './research.service' + +describe('research.service', () => { + describe('search', () => { + it('fetches research articles based on search criteria', async () => { + // Mock successful fetch response + global.fetch = vi.fn().mockResolvedValue({ + json: () => + Promise.resolve({ + items: [{ id: '1', title: 'Sample Research' }], + total: 1, + }), + }) + + // Call search with mock parameters + const result = await researchService.search( + ['sample'], + 'science', + 'Newest', + null, + ) + + // Assert results + expect(result).toEqual({ + items: [{ id: '1', title: 'Sample Research' }], + total: 1, + }) + }) + + it('handles errors in search', async () => { + global.fetch = vi.fn().mockRejectedValue('error') + + const result = await researchService.search( + ['sample'], + 'science', + 'Newest', + null, + ) + + expect(result).toEqual({ items: [], total: 0 }) + }) }) - it('filters by category', () => { - // prepare - const category = 'cat1' + describe('getResearchCategories', () => { + it('fetches research categories', async () => { + global.fetch = vi.fn().mockResolvedValue({ + json: () => + Promise.resolve({ categories: [{ id: 'cat1', name: 'Science' }] }), + }) - // act - exportedForTesting.createSearchQuery([], category, 'MostRelevant', null) + const result = await researchService.getResearchCategories() - // assert - expect(mockWhere).toHaveBeenCalledWith( - 'researchCategory._id', - '==', - category, - ) - }) + expect(result).toEqual([{ id: 'cat1', name: 'Science' }]) + }) + + it('handles errors in fetching research categories', async () => { + global.fetch = vi.fn().mockRejectedValue('error') - it('should not call orderBy if sorting by most relevant', () => { - // act - exportedForTesting.createSearchQuery(['test'], '', 'MostRelevant', null) + const result = await researchService.getResearchCategories() - // assert - expect(mockOrderBy).toHaveBeenCalledTimes(0) + expect(result).toEqual([]) + }) }) - it('should call orderBy when sorting is not MostRelevant', () => { - // act - exportedForTesting.createSearchQuery(['test'], '', 'Newest', null) + describe('getDraftCount', () => { + it('fetches draft count for a user', async () => { + global.fetch = vi.fn().mockResolvedValue({ + json: () => Promise.resolve({ total: 5 }), + }) - // assert - expect(mockOrderBy).toHaveBeenLastCalledWith('_created', 'desc') - }) + const result = await researchService.getDraftCount('user123') + + expect(result).toBe(5) + }) + + it('handles errors in fetching draft count', async () => { + global.fetch = vi.fn().mockRejectedValue('error') - it('should filter by research status', () => { - // act - exportedForTesting.createSearchQuery( - ['test'], - '', - 'Newest', - ResearchStatus.COMPLETED, - ) - - // assert - expect(mockWhere).toHaveBeenCalledWith( - 'researchStatus', - '==', - ResearchStatus.COMPLETED, - ) + const result = await researchService.getDraftCount('user123') + + expect(result).toBe(0) + }) }) - it('should limit results', () => { - // prepare - const take = 12 - - // act - exportedForTesting.createSearchQuery( - ['test'], - '', - 'Newest', - null, - undefined, - take, - ) - - // assert - expect(mockLimit).toHaveBeenLastCalledWith(take) + describe('getDrafts', () => { + it('fetches research drafts for a user', async () => { + global.fetch = vi.fn().mockResolvedValue({ + json: () => + Promise.resolve({ + items: [{ id: 'draft1', title: 'Draft Research' }], + }), + }) + + const result = await researchService.getDrafts('user123') + + expect(result).toEqual([{ id: 'draft1', title: 'Draft Research' }]) + }) + + it('handles errors in fetching drafts', async () => { + global.fetch = vi.fn().mockRejectedValue('error') + + const result = await researchService.getDrafts('user123') + + expect(result).toEqual([]) + }) }) }) diff --git a/src/pages/Research/research.service.ts b/src/pages/Research/research.service.ts index b43dd20bf3..0456b28741 100644 --- a/src/pages/Research/research.service.ts +++ b/src/pages/Research/research.service.ts @@ -1,26 +1,9 @@ -import { - and, - collection, - getCountFromServer, - getDocs, - limit, - orderBy, - query, - startAfter, - where, -} from 'firebase/firestore' -import { IModerationStatus } from 'oa-shared' +import { collection, getDocs, query, where } from 'firebase/firestore' +import { logger } from 'src/logger' import { DB_ENDPOINTS } from 'src/models/dbEndpoints' +import { firestore } from 'src/utils/firebase' import { changeUserReferenceToPlainText } from 'src/utils/mentions.utils' -import { firestore } from '../../utils/firebase' - -import type { - DocumentData, - QueryDocumentSnapshot, - QueryFilterConstraint, - QueryNonFilterConstraint, -} from 'firebase/firestore' import type { ICategory, IResearch, @@ -34,141 +17,65 @@ const search = async ( category: string, sort: ResearchSortOption, status: ResearchStatus | null, - snapshot?: QueryDocumentSnapshot, - take: number = 10, -) => { - const { itemsQuery, countQuery } = createSearchQuery( - words, - category, - sort, - status, - snapshot, - take, - ) - - const documentSnapshots = await getDocs(itemsQuery) - const lastVisible = documentSnapshots.docs - ? documentSnapshots.docs[documentSnapshots.docs.length - 1] - : undefined - - const items = documentSnapshots.docs - ? documentSnapshots.docs.map((x) => x.data() as IResearch.Item) - : [] - const total = (await getCountFromServer(countQuery)).data().count - - return { items, total, lastVisible } -} - -const createSearchQuery = ( - words: string[], - category: string, - sort: ResearchSortOption, - status: ResearchStatus | null, - snapshot?: QueryDocumentSnapshot, - take: number = 10, + lastDocId?: string, ) => { - const collectionRef = collection(firestore, DB_ENDPOINTS.research) - let filters: QueryFilterConstraint[] = [ - and( - where('_deleted', '!=', true), - where('moderation', '==', IModerationStatus.ACCEPTED), - ), - ] - let constraints: QueryNonFilterConstraint[] = [] - - if (words?.length > 0) { - filters = [...filters, and(where('keywords', 'array-contains-any', words))] - } - - if (category) { - filters = [...filters, where('researchCategory._id', '==', category)] - } - - if (status) { - filters = [...filters, where('researchStatus', '==', status)] - } - - if (sort) { - const sortConstraint = getSort(sort) - - if (sortConstraint) { - constraints = [...constraints, sortConstraint] + try { + const url = new URL('/api/research', window.location.origin) + url.searchParams.append('words', words.join(',')) + url.searchParams.append('category', category) + url.searchParams.append('sort', sort) + url.searchParams.append('status', status ?? '') + url.searchParams.append('lastDocId', lastDocId ?? '') + + const response = await fetch(url) + const { items, total } = (await response.json()) as { + items: IResearch.Item[] + total: number } - } - - const countQuery = query(collectionRef, and(...filters), ...constraints) - if (snapshot) { - constraints = [...constraints, startAfter(snapshot)] + return { items, total } + } catch (error) { + logger.error('Failed to fetch research articles', { error }) + return { items: [], total: 0 } } - - const itemsQuery = query( - collectionRef, - and(...filters), - ...constraints, - limit(take), - ) - - return { countQuery, itemsQuery } } const getResearchCategories = async () => { - const collectionRef = collection(firestore, DB_ENDPOINTS.researchCategories) - - return (await getDocs(query(collectionRef))).docs.map( - (x) => x.data() as ICategory, - ) -} + try { + const response = await fetch('/api/research/categories') + const { categories } = (await response.json()) as { + categories: ICategory[] + } -const getSort = (sort: ResearchSortOption) => { - switch (sort) { - case 'MostComments': - return orderBy('totalCommentCount', 'desc') - case 'MostUpdates': - return orderBy('totalUpdates', 'desc') - case 'MostUseful': - return orderBy('totalUsefulVotes', 'desc') - case 'Newest': - return orderBy('_created', 'desc') - case 'LatestUpdated': - return orderBy('_contentModifiedTimestamp', 'desc') + return categories + } catch (error) { + logger.error('Failed to fetch draft count', { error }) + return [] } } -const createDraftQuery = (userId: string) => { - const collectionRef = collection(firestore, DB_ENDPOINTS.research) - const filters = and( - where('_createdBy', '==', userId), - where('moderation', 'in', [ - IModerationStatus.AWAITING_MODERATION, - IModerationStatus.DRAFT, - IModerationStatus.IMPROVEMENTS_NEEDED, - IModerationStatus.REJECTED, - ]), - where('_deleted', '!=', true), - ) - - const countQuery = query(collectionRef, filters) - const itemsQuery = query( - collectionRef, - filters, - orderBy('_contentModifiedTimestamp', 'desc'), - ) - - return { countQuery, itemsQuery } -} - const getDraftCount = async (userId: string) => { - const { countQuery } = createDraftQuery(userId) - - return (await getCountFromServer(countQuery)).data().count + try { + const response = await fetch(`/api/research/drafts/count?userId=${userId}`) + const { total } = (await response.json()) as { total: number } + + return total + } catch (error) { + logger.error('Failed to fetch draft count', { error }) + return 0 + } } const getDrafts = async (userId: string) => { - const { itemsQuery } = createDraftQuery(userId) - const docs = await getDocs(itemsQuery) - - return docs.docs ? docs.docs.map((x) => x.data() as IResearch.Item) : [] + try { + const response = await fetch(`/api/research?drafts=true&userId=${userId}`) + const { items } = (await response.json()) as { items: IResearch.Item[] } + + return items + } catch (error) { + logger.error('Failed to fetch research draft articles', { error }) + return [] + } } const getBySlug = async (slug: string) => { @@ -223,7 +130,3 @@ export const researchService = { getDraftCount, getBySlug, } - -export const exportedForTesting = { - createSearchQuery, -} diff --git a/src/routes/api.research.categories.ts b/src/routes/api.research.categories.ts new file mode 100644 index 0000000000..93ff86de71 --- /dev/null +++ b/src/routes/api.research.categories.ts @@ -0,0 +1,27 @@ +import { json } from '@remix-run/node' +import { collection, getDocs, query, where } from 'firebase/firestore' +import Keyv from 'keyv' +import { DB_ENDPOINTS } from 'src/models/dbEndpoints' +import { firestore } from 'src/utils/firebase' + +import type { ICategory } from 'oa-shared' + +const cache = new Keyv({ ttl: 3600000 }) // ttl: 60 minutes + +// runs on the server +export const loader = async () => { + const cachedCategories = await cache.get('researchCategories') + + // check if cached categories are available, if not - load from db and cache them + if (cachedCategories) return json({ categories: cachedCategories }) + + const collectionRef = collection(firestore, DB_ENDPOINTS.researchCategories) + const categoriesQuery = query(collectionRef, where('_deleted', '!=', true)) + + const categories: ICategory[] = (await getDocs(categoriesQuery)).docs.map( + (x) => x.data() as ICategory, + ) + + cache.set('researchCategories', categories) + return json({ categories }) +} diff --git a/src/routes/api.research.drafts.count.ts b/src/routes/api.research.drafts.count.ts new file mode 100644 index 0000000000..b90094f1a5 --- /dev/null +++ b/src/routes/api.research.drafts.count.ts @@ -0,0 +1,35 @@ +import { json } from '@remix-run/node' +import { + and, + collection, + getCountFromServer, + query, + where, +} from 'firebase/firestore' +import { IModerationStatus } from 'oa-shared' +import { DB_ENDPOINTS } from 'src/models/dbEndpoints' +import { firestore } from 'src/utils/firebase' + +// runs on the server +export const loader = async ({ request }) => { + const url = new URL(request.url) + const searchParams = url.searchParams + const userId: string | null = searchParams.get('userId') + + const collectionRef = collection(firestore, DB_ENDPOINTS.research) + const filters = and( + where('_createdBy', '==', userId), + where('moderation', 'in', [ + IModerationStatus.AWAITING_MODERATION, + IModerationStatus.DRAFT, + IModerationStatus.IMPROVEMENTS_NEEDED, + IModerationStatus.REJECTED, + ]), + where('_deleted', '!=', true), + ) + + const countQuery = query(collectionRef, filters) + const total = (await getCountFromServer(countQuery)).data().count + + return json({ total }) +} diff --git a/src/routes/api.research.ts b/src/routes/api.research.ts new file mode 100644 index 0000000000..f7d64c8337 --- /dev/null +++ b/src/routes/api.research.ts @@ -0,0 +1,178 @@ +import { json } from '@remix-run/node' +import { + and, + collection, + doc, + getCountFromServer, + getDoc, + getDocs, + limit, + orderBy, + query, + startAfter, + where, +} from 'firebase/firestore' +import { IModerationStatus } from 'oa-shared' +import { DB_ENDPOINTS } from 'src/models/dbEndpoints' +import { ITEMS_PER_PAGE } from 'src/pages/Research/constants' +import { firestore } from 'src/utils/firebase' + +import type { + QueryFilterConstraint, + QueryNonFilterConstraint, +} from 'firebase/firestore' +import type { IResearch, ResearchStatus } from 'oa-shared' +import type { ResearchSortOption } from 'src/pages/Research/ResearchSortOptions.ts' + +// runs on the server +export const loader = async ({ request }) => { + const url = new URL(request.url) + const searchParams = url.searchParams + const words: string[] = + searchParams.get('words') != '' + ? searchParams.get('words')?.split(',') ?? [] + : [] + const category: string | null = searchParams.get('category') + const sort: ResearchSortOption = (searchParams.get('sort') ?? + 'LatestUpdated') as ResearchSortOption + const status: ResearchStatus | null = searchParams.get( + 'status', + ) as ResearchStatus + const lastDocId: string | null = searchParams.get('lastDocId') + const drafts: boolean = searchParams.get('drafts') != undefined + const userId: string | null = searchParams.get('userId') + + const { itemsQuery, countQuery } = await createSearchQuery( + words, + category, + sort, + status, + lastDocId, + ITEMS_PER_PAGE, + drafts, + userId, + ) + + const documentSnapshots = await getDocs(itemsQuery) + const items = documentSnapshots.docs + ? documentSnapshots.docs.map((x) => x.data() as IResearch.Item) + : [] + + let total: number | undefined = undefined + // get total only if not requesting drafts + if (!drafts || !userId) + total = (await getCountFromServer(countQuery)).data().count + + return json({ items, total }) +} + +export const action = async ({ request }) => { + const method = request.method + switch (method) { + case 'POST': + // Create new research + return json({ message: 'Created a research' }) + case 'PUT': + // Edit existing research + return json({ message: 'Updated a research' }) + case 'DELETE': + // Delete a research + return json({ message: 'Deleted a research' }) + default: + return json({ message: 'Method Not Allowed' }, { status: 405 }) + } +} + +const createSearchQuery = async ( + words: string[], + category: string | null, + sort: ResearchSortOption, + status: ResearchStatus | null, + lastDocId: string | null, + page_size: number, + drafts: boolean, + userId: string | null, +) => { + let filters: QueryFilterConstraint[] = [] + if (drafts && userId) { + filters = [ + and( + where('_createdBy', '==', userId), + where('moderation', 'in', [ + IModerationStatus.AWAITING_MODERATION, + IModerationStatus.DRAFT, + IModerationStatus.IMPROVEMENTS_NEEDED, + IModerationStatus.REJECTED, + ]), + where('_deleted', '!=', true), + ), + ] + } else { + filters = [ + and( + where('_deleted', '!=', true), + where('moderation', '==', IModerationStatus.ACCEPTED), + ), + ] + } + + let constraints: QueryNonFilterConstraint[] = [] + + const sortByField = getSortByField(sort) + constraints = [orderBy(sortByField, 'desc')] // TODO - add sort by _id to act as a tie breaker + + if (words?.length > 0) { + filters = [...filters, and(where('keywords', 'array-contains-any', words))] + } + + if (category) { + filters = [...filters, where('researchCategory._id', '==', category)] + } + + if (status) { + filters = [...filters, where('researchStatus', '==', status)] + } + + const collectionRef = collection(firestore, DB_ENDPOINTS.research) + const countQuery = query(collectionRef, and(...filters), ...constraints) + + // add pagination only to itemsQuery, not countQuery + if (lastDocId) { + const lastDocSnapshot = await getDoc( + doc(collection(firestore, DB_ENDPOINTS.research), lastDocId), + ) + + if (!lastDocSnapshot.exists) { + throw new Error('Document with the provided ID does not exist.') + } + const lastDocData = lastDocSnapshot.data() as IResearch.Item + + constraints.push(startAfter(lastDocData[sortByField])) // TODO - add startAfter by _id to act as a tie breaker + } + + const itemsQuery = query( + collectionRef, + and(...filters), + ...constraints, + limit(page_size), + ) + + return { countQuery, itemsQuery } +} + +const getSortByField = (sort: ResearchSortOption) => { + switch (sort) { + case 'MostComments': + return 'totalCommentCount' + case 'MostUpdates': + return 'totalUpdates' + case 'MostUseful': + return 'totalUsefulVotes' + case 'Newest': + return '_created' + case 'LatestUpdated': + return '_contentModifiedTimestamp' + default: + return '_contentModifiedTimestamp' + } +}