From af66fd51d2a96a2c260f97ede5f4b76f8bb2e765 Mon Sep 17 00:00:00 2001 From: Taron Date: Fri, 13 Sep 2024 02:28:18 +0400 Subject: [PATCH] feed refactor --- apps/vite/src/routes/feed/index.lazy.tsx | 4 +- packages/app/hooks/pagination/index.ts | 1 + .../hooks/pagination/useInfinitePagination.ts | 58 ++++++ .../modules/feed/components/FeedCard/utils.ts | 11 +- .../feed/components/FeedSearchFilter.tsx | 5 +- .../app/modules/feed/hooks/useAddFavorite.ts | 3 +- packages/app/modules/feed/hooks/useFeed.ts | 37 +++- .../app/modules/feed/hooks/usePublicFeed.ts | 134 ++++-------- packages/app/modules/feed/model.ts | 3 +- .../app/modules/feed/screens/FeedScreen.tsx | 85 +++----- .../feed/widgets/FeedPreview/FeedPreview.tsx | 17 +- .../app/modules/item/hooks/useSimilarItems.ts | 3 +- .../components/PackCard/PackPrimaryCard.tsx | 19 +- .../app/modules/pack/hooks/useSimilarPacks.ts | 3 +- .../app/modules/pack/hooks/useUserPacks.ts | 73 +++++-- .../app/modules/trip/hooks/useUserTrips.ts | 7 +- packages/app/modules/user/hooks/useProfile.ts | 2 +- server/src/drizzle/helpers.ts | 13 ++ server/src/drizzle/methods/pack.ts | 18 +- server/src/helpers/pagination.ts | 32 +++ .../modules/feed/controllers/getPublicFeed.ts | 28 +++ .../feed/controllers/getUserPacksFeed.ts | 29 +++ server/src/modules/feed/index.ts | 1 + server/src/modules/feed/model/feed.ts | 196 ++++++++++++++++++ server/src/modules/feed/model/index.ts | 1 + server/src/modules/feed/models.ts | 12 ++ server/src/modules/feed/routes.ts | 2 + .../modules/feed/services/getFeedService.ts | 32 +++ server/src/modules/feed/services/index.ts | 1 + server/src/routes/trpcRouter.ts | 4 + 30 files changed, 626 insertions(+), 208 deletions(-) create mode 100644 packages/app/hooks/pagination/index.ts create mode 100644 packages/app/hooks/pagination/useInfinitePagination.ts create mode 100644 server/src/drizzle/helpers.ts create mode 100644 server/src/helpers/pagination.ts create mode 100644 server/src/modules/feed/controllers/getPublicFeed.ts create mode 100644 server/src/modules/feed/controllers/getUserPacksFeed.ts create mode 100644 server/src/modules/feed/index.ts create mode 100644 server/src/modules/feed/model/feed.ts create mode 100644 server/src/modules/feed/model/index.ts create mode 100644 server/src/modules/feed/models.ts create mode 100644 server/src/modules/feed/routes.ts create mode 100644 server/src/modules/feed/services/getFeedService.ts create mode 100644 server/src/modules/feed/services/index.ts diff --git a/apps/vite/src/routes/feed/index.lazy.tsx b/apps/vite/src/routes/feed/index.lazy.tsx index d09118ad2..445394492 100644 --- a/apps/vite/src/routes/feed/index.lazy.tsx +++ b/apps/vite/src/routes/feed/index.lazy.tsx @@ -9,8 +9,8 @@ export const Route = createLazyFileRoute('/feed/')({ export default function FeedNav() { return ( - + <> - + ); } diff --git a/packages/app/hooks/pagination/index.ts b/packages/app/hooks/pagination/index.ts new file mode 100644 index 000000000..2bfee8150 --- /dev/null +++ b/packages/app/hooks/pagination/index.ts @@ -0,0 +1 @@ +export * from './useInfinitePagination'; diff --git a/packages/app/hooks/pagination/useInfinitePagination.ts b/packages/app/hooks/pagination/useInfinitePagination.ts new file mode 100644 index 000000000..868dab37d --- /dev/null +++ b/packages/app/hooks/pagination/useInfinitePagination.ts @@ -0,0 +1,58 @@ +import { type Dispatch, type SetStateAction, useEffect, useRef } from 'react'; + +const DEFAULT_LIMIT = 20; + +export const getPaginationInitialParams = (defaultPage = 1) => ({ + limit: DEFAULT_LIMIT, + offset: getOffset(defaultPage, DEFAULT_LIMIT), +}); + +export interface PaginationParams { + limit: number; + offset: number; +} + +interface PaginationOptions { + nextPage?: number; + enabled?: boolean; + defaultPage?: number; +} + +export const useInfinitePagination = ( + fetchFunction: () => void, + paginationParams: PaginationParams, + setPaginationParams: Dispatch>, + options: PaginationOptions = {}, +) => { + const initialRender = useRef(false); + const { nextPage, enabled = true } = options; + + const fetchNextPage = () => { + setPaginationParams((prev) => ({ + ...prev, + offset: nextPage, + })); + }; + + useEffect(() => { + const run = () => { + if (!initialRender.current) { + initialRender.current = true; + return; + } + if (!enabled) { + return; + } + + fetchFunction(); + }; + + run(); + }, [paginationParams.limit, paginationParams.offset, enabled]); + + return { fetchNextPage }; +}; + +function getOffset(page: number, limit: number) { + return (page - 1) * limit; +} diff --git a/packages/app/modules/feed/components/FeedCard/utils.ts b/packages/app/modules/feed/components/FeedCard/utils.ts index e4bdab926..34b4d1097 100644 --- a/packages/app/modules/feed/components/FeedCard/utils.ts +++ b/packages/app/modules/feed/components/FeedCard/utils.ts @@ -29,15 +29,10 @@ export const feedItemPackCardConverter: Converter< ? roundNumber(input.similarityScore) : undefined, weight: input.total_weight, - quantity: - input?.itemPacks?.reduce( - (accumulator, currentValue) => - accumulator + currentValue?.item?.quantity, - 0, - ) ?? 0, + quantity: input.quantity, }, - isUserFavorite: input?.userFavoritePacks?.some( - (obj) => obj?.userId === currentUserId, + isUserFavorite: input?.userFavoritePacks?.some?.( + (userId) => userId === currentUserId, ), favoriteCount: input.favorites_count, }; diff --git a/packages/app/modules/feed/components/FeedSearchFilter.tsx b/packages/app/modules/feed/components/FeedSearchFilter.tsx index 555d337c0..318e34d4b 100644 --- a/packages/app/modules/feed/components/FeedSearchFilter.tsx +++ b/packages/app/modules/feed/components/FeedSearchFilter.tsx @@ -164,7 +164,8 @@ export const FeedSearchFilter = ({ */} )} - - + */} {(feedType === 'userPacks' || feedType === 'userTrips') && ( { // Invalidate and refetch. Update to be more specific utils.getUserFavorites.invalidate(); - utils.getPublicPacks.invalidate(); + utils.getUserPacksFeed.invalidate(); + utils.getPublicFeed.invalidate(); utils.getPacks.invalidate(userId ? { ownerId: userId } : undefined); }, }); diff --git a/packages/app/modules/feed/hooks/useFeed.ts b/packages/app/modules/feed/hooks/useFeed.ts index 499b10a75..9e32e27ea 100644 --- a/packages/app/modules/feed/hooks/useFeed.ts +++ b/packages/app/modules/feed/hooks/useFeed.ts @@ -8,8 +8,8 @@ interface UseFeedResult { isLoading: boolean; refetch?: () => void; setPage?: (page: number) => void; - hasMore?: boolean; - fetchNextPage?: (isInitialFetch?: boolean) => Promise; + nextPage?: number | boolean; + fetchNextPage?: () => void; } export const useFeed = ({ @@ -18,25 +18,46 @@ export const useFeed = ({ feedType = 'public', selectedTypes = { pack: true, trip: true }, id, + searchQuery, }: Partial<{ queryString: string; ownerId: string; feedType: string; selectedTypes: Object; + searchQuery?: string; id: string; }> = {}): UseFeedResult => { + const publicFeed = usePublicFeed( + queryString, + searchQuery, + selectedTypes, + feedType === 'public', + ); + const userPacks = useUserPacks( + ownerId || undefined, + { searchTerm: searchQuery }, + queryString, + feedType === 'userPacks', + ); + const userTrips = useUserTrips( + ownerId || undefined, + feedType === 'userTrips', + ); + const similarPacks = useSimilarPacks(id, feedType === 'similarPacks'); + const similarItems = useSimilarItems(id, feedType === 'similarItems'); + switch (feedType) { case 'public': - return usePublicFeed(queryString, selectedTypes); // Use the typed return from usePublicFeed + return publicFeed; case 'userPacks': - return useUserPacks(ownerId || undefined, queryString); + return userPacks; case 'userTrips': - return useUserTrips(ownerId || undefined); + return userTrips; case 'similarPacks': - return useSimilarPacks(id); + return similarPacks; case 'similarItems': - return useSimilarItems(id); + return similarItems; default: return { data: null, isLoading: true }; } -}; \ No newline at end of file +}; diff --git a/packages/app/modules/feed/hooks/usePublicFeed.ts b/packages/app/modules/feed/hooks/usePublicFeed.ts index 1bb2ab8a7..71a1dbd65 100644 --- a/packages/app/modules/feed/hooks/usePublicFeed.ts +++ b/packages/app/modules/feed/hooks/usePublicFeed.ts @@ -1,101 +1,53 @@ import { queryTrpc } from 'app/trpc'; -import { useState, useEffect } from 'react'; - -type DataType = { - type: string; - id: string; - duration: string; - name: string; - description: string; - createdAt: string | null; - updatedAt: string | null; - pack_id: string | null; - owner_id: string | null; - is_public: boolean | null; -}[]; - -type OptionalDataType = DataType[]; +import { + getPaginationInitialParams, + type PaginationParams, + useInfinitePagination, +} from 'app/hooks/pagination'; +import { useState } from 'react'; export const usePublicFeed = ( - queryString: string, + queryBy, + searchQuery: string, selectedTypes, - initialPage = 1, - initialLimit = 4 + enabled = false, ) => { - const [page, setPage] = useState(initialPage); - const [data, setData] = useState([]); - const [hasMore, setHasMore] = useState(true); - const [isLoading, setIsLoading] = useState(false); - const [isFetchingNextPage, setIsFetchingNextPage] = useState(false); - - // Fetch public packs using the useQuery hook - const { - data: publicPacksData, - isLoading: isPacksLoading, - refetch: refetchPacks, - } = queryTrpc.getPublicPacks.useQuery( - { queryBy: queryString ?? 'Favorite', page, limit: initialLimit }, - { keepPreviousData: true, enabled: selectedTypes.pack } - ); - - // Fetch public trips using the useQuery hook - const { - data: publicTripsData, - isLoading: isTripsLoading, - refetch: refetchTrips, - } = queryTrpc.getPublicTripsRoute.useQuery( - { queryBy: queryString ?? 'Favorite' }, - { enabled: selectedTypes.trip && publicPacksData?.length > 0 } + const [allData, setAllData] = useState([]); + const [pagination, setPagination] = useState( + getPaginationInitialParams(), ); - - // Ensure that fetching logic behaves consistently - useEffect(() => { - const processFetchedData = () => { - if (!isPacksLoading && !isTripsLoading && (publicPacksData || publicTripsData)) { - let newData: OptionalDataType = []; - - // Fetch and append packs - if (selectedTypes.pack && publicPacksData) { - newData = [...newData, ...publicPacksData.map((item) => ({ ...item, type: 'pack' }))]; - } - - // Fetch and append trips - if (selectedTypes.trip && publicTripsData) { - newData = [...newData, ...publicTripsData.map((item) => ({ ...item, type: 'trip' }))]; + const { data, isLoading, refetch } = queryTrpc.getPublicFeed.useQuery( + { queryBy: queryBy ?? 'Favorites', pagination, searchTerm: searchQuery }, + { + enabled, + refetchOnWindowFocus: false, + onSuccess: (newData) => { + if (newData?.data) { + setAllData((prevData) => { + if (pagination.offset === 0) { + return newData.data; + } + + return [...prevData, ...newData.data]; + }); } + }, + onError: (error) => console.error('Error fetching public packs:', error), + }, + ); + const { fetchNextPage } = useInfinitePagination( + refetch, + pagination, + setPagination, + { nextPage: data?.nextOffset, enabled }, + ); - // Update data in state - setData((prevData) => { - return page === initialPage ? newData : [...prevData, ...newData]; // Append for subsequent pages - }); - - // Set `hasMore` based on the data fetched - setHasMore(newData.length === initialLimit); - - // Reset loading states - setIsLoading(false); - setIsFetchingNextPage(false); - } - }; - - processFetchedData(); - }, [publicPacksData, publicTripsData, page, selectedTypes]); - - // Fetch the next page of data - const fetchNextPage = async () => { - if (hasMore && !isLoading && !isFetchingNextPage) { - setIsFetchingNextPage(true); - setPage((prevPage) => prevPage + 1); // Increment the page before fetching new data - - // Fetch packs and trips for the next page - await refetchPacks(); - if (selectedTypes.trip) { - await refetchTrips(); - } - - setIsFetchingNextPage(false); // Reset fetching state after data fetch - } + return { + data: allData, + isLoading, + refetch, + fetchNextPage, + nextPage: data?.nextOffset || false, + error: null, }; - - return { data, isLoading, hasMore, fetchNextPage, refetch: refetchPacks }; }; diff --git a/packages/app/modules/feed/model.ts b/packages/app/modules/feed/model.ts index be9222733..076634bf8 100644 --- a/packages/app/modules/feed/model.ts +++ b/packages/app/modules/feed/model.ts @@ -16,7 +16,8 @@ export interface FeedItem { favorited_by: Array<{ id: string; }>; - userFavoritePacks?: Array<{ userId: string }>; + quantity?: number; + userFavoritePacks?: string[]; favorites_count: number; owner_id: string | { id: string }; destination: string; diff --git a/packages/app/modules/feed/screens/FeedScreen.tsx b/packages/app/modules/feed/screens/FeedScreen.tsx index 23c2408ef..c3b7558f4 100644 --- a/packages/app/modules/feed/screens/FeedScreen.tsx +++ b/packages/app/modules/feed/screens/FeedScreen.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState, useEffect } from 'react'; +import React, { useMemo, useState, useEffect, memo } from 'react'; import { FlatList, View, Platform, ActivityIndicator } from 'react-native'; import { FeedCard, FeedSearchFilter, SearchProvider } from '../components'; import { useRouter } from 'app/hooks/router'; @@ -6,7 +6,7 @@ import { fuseSearch } from 'app/utils/fuseSearch'; import useCustomStyles from 'app/hooks/useCustomStyles'; import { useFeed } from 'app/modules/feed'; import { RefreshControl } from 'react-native'; -import { RText } from '@packrat/ui'; +import { RButton, RText } from '@packrat/ui'; import { useAuthUser } from 'app/modules/auth'; import { disableScreen } from 'app/hoc/disableScreen'; @@ -27,7 +27,7 @@ interface FeedProps { feedType?: string; } -const Feed = ({ feedType = 'public' }: FeedProps) => { +const Feed = memo(function Feed({ feedType = 'public' }: FeedProps) { const router = useRouter(); const [queryString, setQueryString] = useState('Favorite'); const [selectedTypes, setSelectedTypes] = useState({ @@ -41,13 +41,12 @@ const Feed = ({ feedType = 'public' }: FeedProps) => { const user = useAuthUser(); const ownerId = user?.id; const styles = useCustomStyles(loadStyles); - - // Fetch feed data using the useFeed hook - const { data, isLoading, hasMore, fetchNextPage, refetch } = useFeed({ + const { data, isLoading, fetchNextPage, refetch, nextPage } = useFeed({ queryString, ownerId, feedType, selectedTypes, + searchQuery, }); // Refresh data @@ -57,48 +56,19 @@ const Feed = ({ feedType = 'public' }: FeedProps) => { setRefreshing(false); }; - // Fetch more data when reaching the end - const fetchMoreData = async () => { - // Ensure we are not already fetching and that there is more data to fetch - if (!isFetchingNextPage && hasMore && !isLoading) { - setIsFetchingNextPage(true); - await fetchNextPage(); // Call to fetch the next page - setIsFetchingNextPage(false); - } - }; - - // Web-specific scroll detection - useEffect(() => { - if (Platform.OS === 'web') { - const handleScroll = () => { - const scrollTop = window.pageYOffset || document.documentElement.scrollTop; - const windowHeight = window.innerHeight; - const documentHeight = document.documentElement.scrollHeight; - - if (scrollTop + windowHeight >= documentHeight - 50 && !isFetchingNextPage && hasMore) { - fetchMoreData(); - } - }; - - window.addEventListener('scroll', handleScroll); - return () => window.removeEventListener('scroll', handleScroll); // Cleanup - } - }, [isFetchingNextPage, hasMore, isLoading]); - - // Filter data based on search query - const filteredData = useMemo(() => { - if (!data) return []; - const keys = ['name', 'items.name', 'items.category']; - const options = { - threshold: 0.4, - location: 0, - distance: 100, - maxPatternLength: 32, - minMatchCharLength: 1, - }; - const results = fuseSearch(data, searchQuery, keys, options); - return searchQuery ? results.map((result) => result.item) : data; - }, [searchQuery, data]); + // const filteredData = useMemo(() => { + // if (!data) return []; + // const keys = ['name', 'items.name', 'items.category']; + // const options = { + // threshold: 0.4, + // location: 0, + // distance: 100, + // maxPatternLength: 32, + // minMatchCharLength: 1, + // }; + // const results = fuseSearch(data, searchQuery, keys, options); + // return searchQuery ? results.map((result) => result.item) : data; + // }, [searchQuery, data]); const handleTogglePack = () => { setSelectedTypes((prevState) => ({ @@ -126,7 +96,9 @@ const Feed = ({ feedType = 'public' }: FeedProps) => { return ( - + { handleCreateClick={handleCreateClick} /> ( @@ -164,17 +136,22 @@ const Feed = ({ feedType = 'public' }: FeedProps) => { {ERROR_MESSAGES[feedType]} )} - refreshControl={} - onEndReached={fetchMoreData} // Trigger next page fetch + refreshControl={ + + } + // onEndReached={fetchNextPage} // Trigger next page fetch onEndReachedThreshold={0.5} // Trigger when 50% from the bottom showsVerticalScrollIndicator={false} maxToRenderPerBatch={2} /> + {nextPage ? ( + Load more + ) : null} ); -}; +}); const loadStyles = (theme) => ({ mainContainer: { @@ -186,4 +163,4 @@ const loadStyles = (theme) => ({ }, }); -export default disableScreen(Feed, (props) => props.feedType === 'userTrips'); +export default Feed; diff --git a/packages/app/modules/feed/widgets/FeedPreview/FeedPreview.tsx b/packages/app/modules/feed/widgets/FeedPreview/FeedPreview.tsx index 02723e320..b11b191b2 100644 --- a/packages/app/modules/feed/widgets/FeedPreview/FeedPreview.tsx +++ b/packages/app/modules/feed/widgets/FeedPreview/FeedPreview.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { memo } from 'react'; import Carousel from 'app/components/carousel'; import { useFeed } from '../../hooks'; import Loader from 'app/components/Loader'; @@ -36,9 +36,18 @@ const FeedPreviewScroll: React.FC = ({ ); }; -export const FeedPreview: React.FC<{ feedType: string; id?: string }> = ({ +interface FeedPreviewProps { + feedType: string; + id?: string; +} + +export const FeedPreview = memo(function FeedPreview({ feedType, id, -}) => { +}: FeedPreviewProps) { + console.log({ + feedType, + id, + }); return ; -}; +}); diff --git a/packages/app/modules/item/hooks/useSimilarItems.ts b/packages/app/modules/item/hooks/useSimilarItems.ts index fefe2f753..5a6074d81 100644 --- a/packages/app/modules/item/hooks/useSimilarItems.ts +++ b/packages/app/modules/item/hooks/useSimilarItems.ts @@ -1,10 +1,11 @@ import { queryTrpc } from 'app/trpc'; -export const useSimilarItems = (id: string) => { +export const useSimilarItems = (id: string, enabled = true) => { const { data, error, isLoading, refetch } = queryTrpc.getSimilarItems.useQuery( { id, limit: 10 }, { + enabled, refetchOnWindowFocus: true, }, ); diff --git a/packages/app/modules/pack/components/PackCard/PackPrimaryCard.tsx b/packages/app/modules/pack/components/PackCard/PackPrimaryCard.tsx index 4c624cded..3494e7bdd 100644 --- a/packages/app/modules/pack/components/PackCard/PackPrimaryCard.tsx +++ b/packages/app/modules/pack/components/PackCard/PackPrimaryCard.tsx @@ -10,19 +10,22 @@ import { type PackDetails } from 'app/modules/pack/model'; import { DuplicateIcon } from 'app/assets/icons'; import { useItemWeightUnit } from 'app/modules/item'; import { convertWeight } from 'app/utils/convertWeight'; +import { roundNumber } from 'app/utils'; interface PackCardProps extends FeedCardProps {} export const PackPrimaryCard: FC = (props) => { const [weightUnit] = useItemWeightUnit(); - const packDetails = Object.entries(props.details).map(([key, value]) => ({ - key, - label: key, - value: - key === 'weight' - ? `${convertWeight(value, 'g', weightUnit)} ${weightUnit}` - : value, - })); + const packDetails = Object.entries(props.details) + .filter(([key]) => key !== 'similarityScore') + .map(([key, value]) => ({ + key, + label: key, + value: + key === 'weight' + ? `${roundNumber(convertWeight(value, 'kg', weightUnit))} ${weightUnit}` + : value, + })); return ( { +export const useSimilarPacks = (id: string, enabled: boolean = true) => { const { data, error, isLoading, refetch } = queryTrpc.getSimilarPacks.useQuery( { id, limit: 10 }, { + enabled, refetchOnWindowFocus: true, }, ); diff --git a/packages/app/modules/pack/hooks/useUserPacks.ts b/packages/app/modules/pack/hooks/useUserPacks.ts index 451163fb1..3855f4b4b 100644 --- a/packages/app/modules/pack/hooks/useUserPacks.ts +++ b/packages/app/modules/pack/hooks/useUserPacks.ts @@ -1,26 +1,69 @@ import { queryTrpc } from 'app/trpc'; +import { + getPaginationInitialParams, + useInfinitePagination, + type PaginationParams, +} from 'app/hooks/pagination'; +import { useState } from 'react'; -export const useUserPacks = (ownerId: string | undefined, queryString = '') => { - const utils = queryTrpc.useContext(); - // If ownerId is not provided, don’t run the query. - const enabled = !!ownerId; - // Leverage the query hook provided by tRPC - // ... - const { data, error, isLoading, refetch } = queryTrpc.getPacks.useQuery( - { ownerId: ownerId || '', queryBy: queryString }, - { - enabled, // This query will run only if 'enabled' is true. - refetchOnWindowFocus: true, - keepPreviousData: true, - }, +interface QueryOptions { + isPublic?: boolean; + searchTerm?: string; +} + +export const useUserPacks = ( + ownerId: string, + options: QueryOptions = {}, + queryString = '', + queryEnabled = false, +) => { + const { isPublic, searchTerm } = options; + const [allData, setAllData] = useState([]); + const [pagination, setPagination] = useState( + getPaginationInitialParams(), ); + const utils = queryTrpc.useContext(); + const enabled = queryEnabled && !!ownerId; + const { data, error, isLoading, refetch } = + queryTrpc.getUserPacksFeed.useQuery( + { ownerId, isPublic, queryBy: queryString, pagination, searchTerm }, + { + enabled, + refetchOnWindowFocus: true, + keepPreviousData: true, + onSuccess: (newData) => { + if (newData?.data) { + setAllData((prevData) => { + if (pagination.offset === 0) { + return newData.data; + } + + return [...prevData, ...newData.data]; + }); + } + }, + }, + ); utils.getPacks.setData({ ownerId: ownerId || '', queryBy: queryString, }); + const { fetchNextPage } = useInfinitePagination( + refetch, + pagination, + setPagination, + { nextPage: data?.nextOffset, enabled }, + ); + // Extract packs or set an empty array if data is undefined. - const packs = data?.packs || []; - return { data: packs, error, isLoading, refetch }; + return { + data: allData, + error, + isLoading, + refetch, + fetchNextPage, + nextPage: data?.nextOffset || false, + }; }; diff --git a/packages/app/modules/trip/hooks/useUserTrips.ts b/packages/app/modules/trip/hooks/useUserTrips.ts index ca731a001..5f20dd98e 100644 --- a/packages/app/modules/trip/hooks/useUserTrips.ts +++ b/packages/app/modules/trip/hooks/useUserTrips.ts @@ -1,8 +1,11 @@ import { queryTrpc } from 'app/trpc'; -export const useUserTrips = (ownerId: string | undefined) => { +export const useUserTrips = ( + ownerId: string | undefined, + queryEnabled: boolean = true, +) => { // If ownerId is not provided, don’t run the query. - const enabled = !!ownerId; + const enabled = queryEnabled && !!ownerId; // Leverage the query hook provided by tRPC const { data, error, isLoading, refetch } = queryTrpc.getTrips.useQuery( diff --git a/packages/app/modules/user/hooks/useProfile.ts b/packages/app/modules/user/hooks/useProfile.ts index 88057499e..d09fe3722 100644 --- a/packages/app/modules/user/hooks/useProfile.ts +++ b/packages/app/modules/user/hooks/useProfile.ts @@ -14,7 +14,7 @@ export const useProfile = (id = null) => { data: allPacks, isLoading: allPacksLoading, error: allPacksError, - } = useUserPacks(userId); // TODO: Add enabled as parameter + } = useUserPacks(userId, { isPublic: true }, '', true); // TODO: Add enabled as parameter const { data: allTrips, diff --git a/server/src/drizzle/helpers.ts b/server/src/drizzle/helpers.ts new file mode 100644 index 000000000..edbf1e8a3 --- /dev/null +++ b/server/src/drizzle/helpers.ts @@ -0,0 +1,13 @@ +import { sql } from 'drizzle-orm'; + +export function literal(value: string) { + return sql`${value}`; +} + +export const jsonArray = (...fields: any[]) => + sql`json_array(${sql.join(fields, ',')})`; + +export const jsonGroupArray = (field: any) => sql`json_group_array(${field})`; + +export const coalesce = (value1: any, value2: any) => + sql`coalesce(${value1}, ${value2})`; diff --git a/server/src/drizzle/methods/pack.ts b/server/src/drizzle/methods/pack.ts index 8134bc0f0..0ab56a51c 100644 --- a/server/src/drizzle/methods/pack.ts +++ b/server/src/drizzle/methods/pack.ts @@ -151,8 +151,8 @@ export class Pack { sortOption, ownerId, is_public, - page , - limit , + page, + limit, } = options; const filterConditions = []; @@ -175,13 +175,13 @@ export class Pack { const offset = (page - 1) * limit; const packs = await DbClient.instance.query.pack.findMany({ - ...(modifiedFilter && { where: modifiedFilter }), - orderBy: orderByFunction, - ...(includeRelated ? relations : {}), - offset: offset, - limit: limit, - }); - + ...(modifiedFilter && { where: modifiedFilter }), + orderBy: orderByFunction, + ...(includeRelated ? relations : {}), + offset: offset, + limit: limit, + }); + return (await packs).map((pack: any) => ({ ...pack, scores: JSON.parse(pack.scores as string), diff --git a/server/src/helpers/pagination.ts b/server/src/helpers/pagination.ts new file mode 100644 index 000000000..303164b38 --- /dev/null +++ b/server/src/helpers/pagination.ts @@ -0,0 +1,32 @@ +export interface PaginationParams { + limit: number; + offset: number; +} + +const defaultPaginationParams: PaginationParams = { + offset: 0, + limit: 20, +}; + +export function getPaginationParams(params?: PaginationParams) { + if (!params) { + return defaultPaginationParams; + } + + return params; +} + +export function getNextOffset( + pagination: PaginationParams, + totalCount: number, +): number | false { + const { limit, offset } = pagination; + + const nextOffset = offset + limit; + + if (nextOffset >= totalCount) { + return false; + } + + return nextOffset; +} diff --git a/server/src/modules/feed/controllers/getPublicFeed.ts b/server/src/modules/feed/controllers/getPublicFeed.ts new file mode 100644 index 000000000..5c98e2230 --- /dev/null +++ b/server/src/modules/feed/controllers/getPublicFeed.ts @@ -0,0 +1,28 @@ +import { getNextOffset } from 'src/helpers/pagination'; +import { protectedProcedure } from '../../../trpc'; +import { getFeedService } from '../services'; +import { z } from 'zod'; + +export function getPublicFeedRoute() { + return protectedProcedure + .input( + z.object({ + queryBy: z.string(), + searchTerm: z.string().optional(), + excludeType: z + .union([z.literal('trips'), z.literal('packs')]) + .optional(), + pagination: z.object({ limit: z.number(), offset: z.number() }), + }), + ) + .query(async (opts) => { + const { queryBy, searchTerm, excludeType, pagination } = opts.input; + const { data, totalCount } = await getFeedService( + queryBy, + { searchTerm, isPublic: true }, + excludeType, + pagination, + ); + return { data, nextOffset: getNextOffset(pagination, totalCount) }; + }); +} diff --git a/server/src/modules/feed/controllers/getUserPacksFeed.ts b/server/src/modules/feed/controllers/getUserPacksFeed.ts new file mode 100644 index 000000000..92e5b7350 --- /dev/null +++ b/server/src/modules/feed/controllers/getUserPacksFeed.ts @@ -0,0 +1,29 @@ +import { getNextOffset } from 'src/helpers/pagination'; +import { protectedProcedure } from '../../../trpc'; +import { getFeedService } from '../services'; +import { z } from 'zod'; + +export function getUserPacksFeedRoute() { + return protectedProcedure + .input( + z.object({ + queryBy: z.string(), + ownerId: z.string(), + isPublic: z.boolean().optional(), + searchTerm: z.string().optional(), + pagination: z + .object({ limit: z.number(), offset: z.number() }) + .optional(), + }), + ) + .query(async (opts) => { + const { queryBy, searchTerm, ownerId, pagination, isPublic } = opts.input; + const { data, totalCount } = await getFeedService( + queryBy, + { searchTerm, ownerId }, + 'trips', + pagination, + ); + return { data, nextOffset: getNextOffset(pagination, totalCount) }; + }); +} diff --git a/server/src/modules/feed/index.ts b/server/src/modules/feed/index.ts new file mode 100644 index 000000000..a3820983e --- /dev/null +++ b/server/src/modules/feed/index.ts @@ -0,0 +1 @@ +export * from './routes'; diff --git a/server/src/modules/feed/model/feed.ts b/server/src/modules/feed/model/feed.ts new file mode 100644 index 000000000..a3369c4d9 --- /dev/null +++ b/server/src/modules/feed/model/feed.ts @@ -0,0 +1,196 @@ +import { and, eq, like, sql } from 'drizzle-orm'; +import { DbClient } from '../../../db/client'; +import { + item, + itemPacks, + pack, + trip, + userFavoritePacks, +} from '../../../db/schema'; +import { literal } from 'src/drizzle/helpers'; +import { convertWeight } from '../../../utils/convertWeight'; +import { + getPaginationParams, + type PaginationParams, +} from '../../../helpers/pagination'; +import { FeedQueryBy, Modifiers } from '../models'; + +export class Feed { + async findFeed( + queryBy: FeedQueryBy, + modifiers?: Modifiers, + excludeType?: 'trips' | 'packs', + pagination?: PaginationParams, + ) { + try { + let packsQuery = DbClient.instance + .select({ + id: pack.id, + createdAt: pack.createdAt, + name: pack.name, + owner_id: pack.owner_id, + grades: pack.grades, + scores: pack.scores, + type: literal('pack'), + description: literal(''), + destination: literal(''), + favorites_count: sql`COALESCE(COUNT(DISTINCT ${userFavoritePacks.userId}), 0) as favorites_count`, + quantity: sql`COALESCE(SUM(DISTINCT ${item.quantity}), 0)`, + userFavorites: sql`GROUP_CONCAT(DISTINCT ${userFavoritePacks.userId})`, + total_weight: sql`COALESCE(SUM(DISTINCT ${item.weight} * ${item.quantity}), 0)`, + }) + .from(pack) + .leftJoin(userFavoritePacks, eq(pack.id, userFavoritePacks.packId)) + .leftJoin(itemPacks, eq(pack.id, itemPacks.packId)) + .leftJoin(item, eq(itemPacks.itemId, item.id)) + .groupBy(pack.id); + + if (modifiers) { + packsQuery = packsQuery.where( + this.generateWhereConditions(modifiers, pack), + ); + } + + let tripsQuery = DbClient.instance + .select({ + id: trip.id, + createdAt: trip.createdAt, + name: trip.name, + owner_id: trip.owner_id, + grades: literal(''), + scores: literal(''), + type: literal('trip'), + description: trip.description, + destination: trip.destination, + favorites_count: literal(null), + quantity: literal(null), + userFavorites: literal(null), + total_weight: literal(null), + }) + .from(trip); + + if (modifiers) { + tripsQuery = tripsQuery.where( + this.generateWhereConditions(modifiers, trip), + ); + } + + if (excludeType === 'packs') { + packsQuery = null; + } + if (excludeType === 'trips') { + tripsQuery = null; + } + + packsQuery = this.applyPackOrders( + packsQuery, + queryBy, + tripsQuery === null, + ); + + let feedQuery = null; + if (packsQuery && tripsQuery) { + feedQuery = packsQuery.union(tripsQuery); + } else if (packsQuery) { + feedQuery = packsQuery; + } else if (tripsQuery) { + feedQuery = tripsQuery; + } + + const totalCountQuery = await DbClient.instance + .select({ + totalCount: sql`COUNT(*)`, + }) + .from(feedQuery.as('feed')) + .all(); + const { limit, offset } = getPaginationParams(pagination); + + if (queryBy === 'Oldest' || queryBy === 'Most Recent') { + const orderDirection = queryBy === 'Most Recent' ? 'desc' : 'asc'; + feedQuery = feedQuery.orderBy((row) => row.createdAt, orderDirection); + } + + const feedData = await feedQuery.limit(limit).offset(offset).all(); + const data = (await feedData).map((data) => ({ + ...data, + scores: JSON.parse(data.scores as string), + grades: JSON.parse(data.grades as string), + total_score: this.computeTotalScores(data), + userFavoritePacks: this.computeUserFavoritePacks(data), + })); + + return { + totalCount: totalCountQuery?.[0]?.totalCount || 0, + data, + }; + } catch (error) { + throw new Error(`Error finding public feed: ${error.message}`); + } + } + + computeFavouritesCount(pack) { + const userFavorites = this.computeUserFavoritePacks(pack); + return userFavorites.length; + } + + applyPackOrders(packQuery, queryBy: FeedQueryBy, enabled: boolean) { + if (!packQuery || !enabled) return packQuery; + + if ( + queryBy === 'Favorite' || + queryBy === 'Heaviest' || + queryBy === 'Lightest' + ) { + const orderConfig = { + Favorite: { field: 'favorites_count', orderDirection: 'desc' }, + Heaviest: { field: 'total_weight', orderDirection: 'desc' }, + Lightest: { field: 'total_weight', orderDirection: 'asc' }, + }; + return packQuery.orderBy( + orderConfig[queryBy].field, + orderConfig[queryBy].orderDirection, + ); + } + + return packQuery; + } + + computeTotalScores(pack) { + if (!pack.scores) return 0; + const scores = JSON.parse(pack.scores); + const scoresArray: number[] = Object.values(scores); + const sum: number = scoresArray.reduce( + (total: number, score: number) => total + score, + 0, + ); + const average: number = + scoresArray.length > 0 ? sum / scoresArray.length : 0; + + return Math.round(average * 100) / 100; + } + + computeUserFavoritePacks({ userFavorites }) { + return userFavorites?.split?.(',') || []; + } + + generateWhereConditions( + modifiers: Modifiers, + table: typeof trip | typeof pack, + ) { + const conditions = []; + + if (modifiers.isPublic !== undefined) { + conditions.push(eq(table.is_public, modifiers.isPublic)); + } + + if (modifiers.ownerId) { + conditions.push(eq(table.owner_id, modifiers.ownerId)); + } + + if (modifiers.searchTerm) { + conditions.push(like(table.name, `%${modifiers.searchTerm}%`)); + } + + return conditions.length > 0 ? and(...conditions) : undefined; + } +} diff --git a/server/src/modules/feed/model/index.ts b/server/src/modules/feed/model/index.ts new file mode 100644 index 000000000..0b2a3adbd --- /dev/null +++ b/server/src/modules/feed/model/index.ts @@ -0,0 +1 @@ +export * from './feed'; diff --git a/server/src/modules/feed/models.ts b/server/src/modules/feed/models.ts new file mode 100644 index 000000000..e04bc3861 --- /dev/null +++ b/server/src/modules/feed/models.ts @@ -0,0 +1,12 @@ +export interface Modifiers { + isPublic?: boolean; + ownerId?: string; + searchTerm?: string; +} + +export type FeedQueryBy = + | 'Favorite' + | 'Most Recent' + | 'Lightest' + | 'Heaviest' + | 'Oldest'; diff --git a/server/src/modules/feed/routes.ts b/server/src/modules/feed/routes.ts new file mode 100644 index 000000000..34feffef9 --- /dev/null +++ b/server/src/modules/feed/routes.ts @@ -0,0 +1,2 @@ +export { getPublicFeedRoute } from './controllers/getPublicFeed'; +export { getUserPacksFeedRoute } from './controllers/getUserPacksFeed'; diff --git a/server/src/modules/feed/services/getFeedService.ts b/server/src/modules/feed/services/getFeedService.ts new file mode 100644 index 000000000..073c87831 --- /dev/null +++ b/server/src/modules/feed/services/getFeedService.ts @@ -0,0 +1,32 @@ +// services/tripService.ts + +import { PaginationParams } from '../../../helpers/pagination'; +import { Feed } from '../model'; +import { Modifiers } from '../models'; + +/** + * Retrieves public trips based on the given query parameter. + * @param {PrismaClient} prisma - Prisma client. + * @param {string} queryBy - The query parameter to sort the trips. + * @return {Promise} The public trips. + */ +export const getFeedService = async ( + queryBy: string, + modifiers?: Modifiers, + excludeType?: 'trips' | 'packs', + pagination?: PaginationParams, +) => { + try { + const feedClass = new Feed(); + const publicFeed = await feedClass.findFeed( + queryBy, + modifiers, + excludeType, + pagination, + ); + return publicFeed; + } catch (error) { + console.error(error); + throw new Error('Trips cannot be found'); + } +}; diff --git a/server/src/modules/feed/services/index.ts b/server/src/modules/feed/services/index.ts new file mode 100644 index 000000000..a9cc7103f --- /dev/null +++ b/server/src/modules/feed/services/index.ts @@ -0,0 +1 @@ +export * from './getFeedService'; diff --git a/server/src/routes/trpcRouter.ts b/server/src/routes/trpcRouter.ts index 3fddd97c8..e41b9ae74 100644 --- a/server/src/routes/trpcRouter.ts +++ b/server/src/routes/trpcRouter.ts @@ -93,6 +93,7 @@ import { } from '../controllers/getOsm'; import { router as trpcRouter } from '../trpc'; +import { getPublicFeedRoute, getUserPacksFeedRoute } from '../modules/feed'; export const appRouter = trpcRouter({ getUserById: getUserByIdRoute(), @@ -113,6 +114,9 @@ export const appRouter = trpcRouter({ updatePassword: updatePasswordRoute(), // weather routes getWeather: getWeatherRoute(), + // feed routes + getPublicFeed: getPublicFeedRoute(), + getUserPacksFeed: getUserPacksFeedRoute(), // trips routes getPublicTripsRoute: getPublicTripsRoute(), getTrips: getTripsRoute(),