Skip to content

Commit

Permalink
feat: create API for questions search
Browse files Browse the repository at this point in the history
  • Loading branch information
onim-at committed Nov 24, 2024
1 parent 77318fc commit c1f0579
Show file tree
Hide file tree
Showing 5 changed files with 203 additions and 157 deletions.
17 changes: 6 additions & 11 deletions src/pages/Question/QuestionListing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,20 @@ import { logger } from 'src/logger'
import { questionService } from 'src/pages/Question/question.service'
import { Flex, Heading } from 'theme-ui'

import { ITEMS_PER_PAGE } from './constants'
import { headings, listing } from './labels'
import { QuestionFilterHeader } from './QuestionFilterHeader'
import { QuestionListItem } from './QuestionListItem'

import type { DocumentData, QueryDocumentSnapshot } from 'firebase/firestore'
import type { IQuestion } from 'oa-shared'
import type { QuestionSortOption } from './QuestionSortOptions'

export const QuestionListing = () => {
const [isFetching, setIsFetching] = useState<boolean>(true)
const [questions, setQuestions] = useState<IQuestion.Item[]>([])
const [total, setTotal] = useState<number>(0)
const [lastVisible, setLastVisible] = useState<
QueryDocumentSnapshot<DocumentData, DocumentData> | undefined
>(undefined)
const [lastVisibleId, setLastVisibleId] = useState<string | undefined>(
undefined,
)
const { userStore } = useCommonStores().stores

const [searchParams, setSearchParams] = useSearchParams()
Expand All @@ -46,9 +44,7 @@ export const QuestionListing = () => {
}
}, [q, category, sort])

const fetchQuestions = async (
skipFrom?: QueryDocumentSnapshot<DocumentData, DocumentData>,
) => {
const fetchQuestions = async (skipFrom?: string | undefined) => {
setIsFetching(true)

try {
Expand All @@ -59,7 +55,6 @@ export const QuestionListing = () => {
category,
sort,
skipFrom,
ITEMS_PER_PAGE,
)

if (result) {
Expand All @@ -70,7 +65,7 @@ export const QuestionListing = () => {
setQuestions(result.items)
}

setLastVisible(result.lastVisible)
setLastVisibleId(result.lastVisibleId)

setTotal(result.total)
}
Expand Down Expand Up @@ -151,7 +146,7 @@ export const QuestionListing = () => {
justifyContent: 'center',
}}
>
<Button type="button" onClick={() => fetchQuestions(lastVisible)}>
<Button type="button" onClick={() => fetchQuestions(lastVisibleId)}>
{listing.loadMore}
</Button>
</Flex>
Expand Down
175 changes: 30 additions & 145 deletions src/pages/Question/question.service.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,13 @@
import { collection, getDocs, query, where } from 'firebase/firestore'
import {
and,
collection,
getCountFromServer,
getDocs,
limit,
orderBy,
query,
startAfter,
where,
} from 'firebase/firestore'
import { IModerationStatus } from 'oa-shared'
import { DB_ENDPOINTS } from 'src/models/dbEndpoints'
DB_ENDPOINTS,
type ICategory,
type IQuestion,
type IQuestionDB,
} from 'oa-shared'
import { logger } from 'src/logger'
import { firestore } from 'src/utils/firebase'

import { firestore } from '../../utils/firebase'

import type {
DocumentData,
QueryDocumentSnapshot,
QueryFilterConstraint,
QueryNonFilterConstraint,
} from 'firebase/firestore'
import type { ICategory, IQuestion, IQuestionDB } from 'oa-shared'
import type { QuestionSortOption } from './QuestionSortOptions'

export enum QuestionSearchParams {
Expand All @@ -33,135 +20,39 @@ const search = async (
words: string[],
category: string,
sort: QuestionSortOption,
snapshot?: QueryDocumentSnapshot<DocumentData, DocumentData>,
take: number = 10,
) => {
const { itemsQuery, countQuery } = createQueries(
words,
category,
sort,
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 IQuestion.Item)
: []
const total = (await getCountFromServer(countQuery)).data().count

return { items, total, lastVisible }
}

const createQueries = (
words: string[],
category: string,
sort: QuestionSortOption,
snapshot?: QueryDocumentSnapshot<DocumentData, DocumentData>,
take: number = 10,
lastDocId?: string | undefined,
) => {
const collectionRef = collection(firestore, DB_ENDPOINTS.questions)
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('questionCategory._id', '==', category)]
}

if (sort) {
const sortConstraint = getSort(sort)

if (sortConstraint) {
constraints = [...constraints, sortConstraint]
try {
const response = await fetch(
`/api/questions?words=${words.join(',')}&category=${category}&sort=${sort}&lastDocId=${lastDocId ?? ''}`,
)
const { items, total } = (await response.json()) as {
items: IQuestion.Item[]
total: number
}
const lastVisibleId = items ? items[items.length - 1]._id : undefined
return { items, total, lastVisibleId }
} catch (error) {
logger.error('Failed to fetch questions', { error })
return { items: [], total: 0 }
}

const countQuery = query(collectionRef, and(...filters), ...constraints)

if (snapshot) {
constraints = [...constraints, startAfter(snapshot)]
}

const itemsQuery = query(
collectionRef,
and(...filters),
...constraints,
limit(take),
)

return { countQuery, itemsQuery }
}

const getQuestionCategories = async () => {
const collectionRef = collection(firestore, DB_ENDPOINTS.questionCategories)

return (await getDocs(query(collectionRef))).docs.map(
(x) => x.data() as ICategory,
)
}

const createDraftQuery = (userId: string) => {
const collectionRef = collection(firestore, DB_ENDPOINTS.questions)
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('_modified', 'desc'))

return { countQuery, itemsQuery }
}

const getDraftCount = async (userId: string) => {
const { countQuery } = createDraftQuery(userId)

return (await getCountFromServer(countQuery)).data().count
}

const getDrafts = async (userId: string) => {
const { itemsQuery } = createDraftQuery(userId)
const docs = await getDocs(itemsQuery)

return docs.docs ? docs.docs.map((x) => x.data() as IQuestion.Item) : []
}
try {
const response = await fetch(`/api/questions/categories`)
const responseJson = (await response.json()) as {
categories: ICategory[]
}

const getSort = (sort: QuestionSortOption) => {
switch (sort) {
case 'Comments':
return orderBy('commentCount', 'desc')
case 'LeastComments':
return orderBy('commentCount', 'asc')
case 'Newest':
return orderBy('_created', 'desc')
case 'LatestComments':
return orderBy('latestCommentDate', 'desc')
case 'LatestUpdated':
return orderBy('_modified', 'desc')
return responseJson.categories
} catch (error) {
logger.error('Failed to fetch questions', { error })
return []
}
}

const getBySlug = async (slug: string) => {
// Get all that match the slug, to avoid creating an index (blocker for cypress tests)
let snapshot = await getDocs(
query(
collection(firestore, DB_ENDPOINTS.questions),
Expand Down Expand Up @@ -189,11 +80,5 @@ const getBySlug = async (slug: string) => {
export const questionService = {
search,
getQuestionCategories,
getDraftCount,
getDrafts,
getBySlug,
}

export const exportedForTesting = {
createQueries,
}
24 changes: 24 additions & 0 deletions src/routes/api.questions.categories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { json } from '@remix-run/node'
import { collection, getDocs, query } 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<ICategory[]>({ ttl: 3600000 }) // ttl: 60 minutes

export const loader = async () => {
const cachedCategories = await cache.get('questionCategories')

// 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.questionCategories)
const categories = (await getDocs(query(collectionRef))).docs.map(
(x) => x.data() as ICategory,
)

cache.set('questionCategories', categories)
return json({ categories })
}
Loading

0 comments on commit c1f0579

Please sign in to comment.