diff --git a/backend/internal/service/handler/reviews/get_user_following_reviews.go b/backend/internal/service/handler/reviews/get_user_following_reviews.go new file mode 100644 index 00000000..01c5cf20 --- /dev/null +++ b/backend/internal/service/handler/reviews/get_user_following_reviews.go @@ -0,0 +1,16 @@ +package reviews + +import ( + "github.com/gofiber/fiber/v2" +) + +func (h *Handler) GetUserFollowingReviewsOfMedia(c *fiber.Ctx) error { + + userId := c.Params("userId") + mediaId := c.Params("mediaId") + typeString := c.Query("media_type") + + review, _ := h.reviewRepository.GetUserFollowingReviewsOfMedia(c.Context(), typeString, mediaId, userId) + + return c.Status(fiber.StatusOK).JSON(review) +} diff --git a/backend/internal/service/server.go b/backend/internal/service/server.go index 4e0fa789..55786552 100644 --- a/backend/internal/service/server.go +++ b/backend/internal/service/server.go @@ -99,6 +99,8 @@ func setupRoutes(app *fiber.App, repo *storage.Repository, config config.Config) // Get Reviews by ID which can be used to populate a preview r.Get("/:id", reviewHandler.GetReviewByID) r.Get("/media/:mediaId/:userID", reviewHandler.GetUserReviewsOfMedia) + r.Get("/media/:mediaId/:userID/following", reviewHandler.GetUserFollowingReviewsOfMedia) + r.Get("/user/:id", reviewHandler.GetReviewsByUserID) r.Post("/vote", func(c *fiber.Ctx) error { return reviewHandler.UserVote(c, "review") diff --git a/backend/internal/storage/postgres/schema/review.go b/backend/internal/storage/postgres/schema/review.go index a2841878..8abf4522 100644 --- a/backend/internal/storage/postgres/schema/review.go +++ b/backend/internal/storage/postgres/schema/review.go @@ -412,6 +412,135 @@ func (r *ReviewRepository) GetUserReviewsOfMedia(ctx context.Context, media_type } +func (r *ReviewRepository) GetUserFollowingReviewsOfMedia(ctx context.Context, media_type string, mediaID string, userID string) ([]*models.Preview, error) { + query := ` + SELECT + r.id, + r.user_id, + u.username, + u.display_name, + u.profile_picture, + r.media_type, + r.media_id, + r.rating, + r.title, + r.comment, + r.created_at, + r.updated_at, + COALESCE(a.cover, t.cover) AS media_cover, + COALESCE(a.title, t.title) AS media_title, + COALESCE(a.artists, t.artists) AS media_artist, + ARRAY_AGG(tag.name) FILTER (WHERE tag.name IS NOT NULL) AS tags + FROM review r + INNER JOIN "user" u ON u.id = r.user_id + LEFT JOIN ( + SELECT t.title, t.id, STRING_AGG(ar.name, ', ') AS artists, cover + FROM track t + JOIN track_artist ta on t.id = ta.track_id + JOIN artist ar ON ta.artist_id = ar.id + JOIN album a on t.album_id = a.id + GROUP BY t.id, cover, t.title + ) t ON r.media_type = 'track' AND r.media_id = t.id + LEFT JOIN ( + SELECT a.id, a.title, STRING_AGG(ar.name, ', ') AS artists, cover + FROM album a + JOIN album_artist aa on a.id = aa.album_id + JOIN artist ar ON aa.artist_id = ar.id + GROUP BY a.id, cover, a.title + ) a ON r.media_type = 'album' AND r.media_id = a.id + LEFT JOIN review_tag rt ON r.id = rt.review_id + LEFT JOIN tag tag ON rt.tag_id = tag.id + LEFT JOIN ( + SELECT post_id as review_id, COUNT(*) AS vote_count + FROM user_vote + WHERE post_type = 'review' + GROUP BY post_id + ) v ON r.id = v.review_id + WHERE r.media_id = $2 AND r.media_type = $3 AND r.user_id IN ( + SELECT followee_id FROM follower WHERE follower_id = $1 + ) + GROUP BY r.id, r.user_id, u.username, u.display_name, u.profile_picture, r.media_type, r.media_id, r.rating, r.comment, r.created_at, r.updated_at, media_cover, media_title, media_artist, v.vote_count + ` + + rows, err := r.Query(ctx, query, userID, mediaID, media_type) + + if err != nil { + fmt.Println(err) + return nil, err + } + defer rows.Close() + + var previews []*models.Preview + + // Scan results into the feedPosts slice + for rows.Next() { + var preview models.Preview + var title, comment sql.NullString // Use sql.NullString for nullable strings + err := rows.Scan( + &preview.ReviewID, + &preview.UserID, + &preview.Username, + &preview.DisplayName, + &preview.ProfilePicture, + &preview.MediaType, + &preview.MediaID, + &preview.Rating, + &title, + &comment, + &preview.CreatedAt, + &preview.UpdatedAt, + &preview.MediaCover, + &preview.MediaTitle, + &preview.MediaArtist, + &preview.Tags, + ) + if err != nil { + fmt.Println(err) + return nil, err + } + + // Assign comment to feedPost.Comment, handling null case + if comment.Valid { + preview.Comment = &comment.String // Point to the string if valid + } else { + preview.Comment = nil // Set to nil if null + } + + if title.Valid { + preview.Title = &title.String // Point to the string if valid + } else { + preview.Title = nil // Set to nil if null + } + + // Ensure tags is an empty array if null + if preview.Tags == nil { + preview.Tags = []string{} + } + + // Fetch review statistics for the current review + reviewStat, err := r.GetReviewStats(ctx, strconv.Itoa(preview.ReviewID)) + if err != nil { + return nil, err + } + + // If reviewStat is not nil, populate the corresponding fields in FeedPost + if reviewStat != nil { + preview.ReviewStat = *reviewStat + } + + // Append the populated FeedPost to the feedPosts slice + previews = append(previews, &preview) + } + + // Check for errors after looping through rows + if err := rows.Err(); err != nil { + return nil, err + } + + return previews, nil + +} + func (r *ReviewRepository) GetReviewsByUserID(ctx context.Context, userId string) ([]*models.Preview, error) { query := ` diff --git a/backend/internal/storage/storage.go b/backend/internal/storage/storage.go index b84f465e..d246e26c 100644 --- a/backend/internal/storage/storage.go +++ b/backend/internal/storage/storage.go @@ -39,6 +39,7 @@ type UserRepository interface { type ReviewRepository interface { GetUserReviewsOfMedia(ctx context.Context, media_type string, mediaID string, userID string) ([]*models.Preview, error) + GetUserFollowingReviewsOfMedia(ctx context.Context, media_type string, mediaID string, userID string) ([]*models.Preview, error) GetReviewsByUserID(ctx context.Context, id string) ([]*models.Preview, error) CreateReview(ctx context.Context, review *models.Review) (*models.Review, error) ReviewExists(ctx context.Context, id string) (bool, error) diff --git a/frontend/app/MediaPage.tsx b/frontend/app/MediaPage.tsx index bd1cb1d5..e4b62be0 100644 --- a/frontend/app/MediaPage.tsx +++ b/frontend/app/MediaPage.tsx @@ -181,7 +181,7 @@ export default function MediaPage() { )} - + {reviews?.slice(0, 5).map((review) => ( diff --git a/frontend/app/MediaReviewsPage.tsx b/frontend/app/MediaReviewsPage.tsx index 2615b8e4..c4453b67 100644 --- a/frontend/app/MediaReviewsPage.tsx +++ b/frontend/app/MediaReviewsPage.tsx @@ -1,11 +1,10 @@ -import React from "react"; -import { useState, useEffect } from "react"; -import { View, ScrollView, Image, Text } from "react-native"; -import ReviewPreview from "@/components/ReviewPreview"; -import Filter from "@/components/search/Filter"; +import React, { useState, useEffect } from "react"; +import { View, ScrollView, Image, Text, StyleSheet } from "react-native"; import axios from "axios"; import { useLocalSearchParams } from "expo-router"; import HeaderComponent from "@/components/HeaderComponent"; +import ReviewPreview from "@/components/ReviewPreview"; +import Filter from "@/components/search/Filter"; import Vinyl from "@/assets/images/media-vinyl.svg"; const MediaReviewsPage = () => { @@ -83,26 +82,61 @@ const MediaReviewsPage = () => { const reviews = response.data; setUserReviews(reviews); - // Calculate the average score - const totalScore = response.data.reduce( - (sum: any, review: { rating: any }) => sum + review.rating, - 0, - ); // Sum of all ratings - const averageScore = - reviews.length > 0 ? totalScore / reviews.length : 0; // Avoid division by 0 - // Update userScore in mediaStats - setMediaStats((prev) => ({ - ...prev, - userScore: averageScore, - userRatings: reviews.length, - })); + if (reviews) { + // Calculate the average score + const totalScore = reviews.reduce( + (sum: any, review: { rating: any }) => sum + review.rating, + 0, + ); // Sum of all ratings + const averageScore = + reviews.length > 0 ? totalScore / reviews.length : 0; // Avoid division by 0 + // Update userScore in mediaStats + setMediaStats((prev) => ({ + ...prev, + userScore: averageScore, + userRatings: reviews.length, + })); + } } catch (error) { console.error(error); } }; - // TODO ALEX: Here you would also fetch the reviews from friends + const fetchFriendReviews = async () => { + try { + const response = await axios.get( + `${BASE_URL}/reviews/media/${media_id}/${user_id}/following`, + { + params: { + media_type: media_type, + }, + }, + ); + + const reviews = response.data; + if (reviews) { + setFriendsReviews(reviews); + + // Calculate the average score + const totalScore = reviews.reduce( + (sum: any, review: { rating: any }) => sum + review.rating, + 0, + ); // Sum of all ratings + const averageScore = + reviews.length > 0 ? totalScore / reviews.length : 0; // Avoid division by 0 + // Update userScore in mediaStats + setMediaStats((prev) => ({ + ...prev, + friendScore: averageScore, + friendRatings: reviews.length, + })); + } + } catch (error) { + console.error(error); + } + }; + fetchFriendReviews(); fetchAll(); fetchMediaCover(); fetchUserReviews(); @@ -120,102 +154,103 @@ const MediaReviewsPage = () => { }; return ( - - - - - - - {mediaCover && ( - - )} - - - {selectedFilter === "you" && ( - - - {mediaStats.userScore.toFixed(1)} - - Your Avg Rating - - )} - {selectedFilter === "friend" && ( - - - {mediaStats.friendScore.toFixed(1)} - - Friend Rating - - )} - {selectedFilter === "all" && ( - - - {mediaStats.avgScore.toFixed(1)} - - Avg Rating - - )} - {selectedFilter === "you" && ( - <> - - {formatLargeNumber(mediaStats.userRatings)} - - Your Ratings - - )} - {selectedFilter === "friend" && ( - <> - - {formatLargeNumber(mediaStats.friendRatings)} - - Friends Ratings - - )} - {selectedFilter === "all" && ( - <> - - {formatLargeNumber(mediaStats.totalRatings)} - - Total Ratings - - )} - + + + + + + {mediaCover && ( + + )} - - + {selectedFilter === "you" && ( - - {userReviews.map((review, index) => { - return ; - })} + + + {mediaStats.userScore.toFixed(1)} + + Your Avg Rating )} {selectedFilter === "friend" && ( - // TODO ALEX: Map each fetched review to a ReviewPreview component which will take care of the rest + + + {mediaStats.friendScore.toFixed(1)} + + Friend Avg Rating + )} {selectedFilter === "all" && ( - - {allReviews.map((review, index) => { - return ; - })} + + {mediaStats.avgScore.toFixed(1)} + Avg Rating )} + {selectedFilter === "you" && ( + <> + + {formatLargeNumber(mediaStats.userRatings)} + + Your Ratings + + )} + {selectedFilter === "friend" && ( + <> + + {formatLargeNumber(mediaStats.friendRatings)} + + Friends Ratings + + )} + {selectedFilter === "all" && ( + <> + + {formatLargeNumber(mediaStats.totalRatings)} + + Total Ratings + + )} - - + + + + {selectedFilter === "you" && ( + + {userReviews && + userReviews.map((review, index) => { + return ; + })} + + )} + {selectedFilter === "friend" && ( + + {friendsReviews && + friendsReviews.map((review, index) => { + return ; + })} + + )} + {selectedFilter === "all" && ( + + {allReviews && + allReviews.map((review, index) => { + return ; + })} + + )} + + ); }; -import { StyleSheet } from "react-native"; - const styles = StyleSheet.create({ container: { flex: 1, diff --git a/frontend/components/media/FriendRatings.tsx b/frontend/components/media/FriendRatings.tsx index 7860b19c..7fb45073 100644 --- a/frontend/components/media/FriendRatings.tsx +++ b/frontend/components/media/FriendRatings.tsx @@ -1,21 +1,53 @@ import React from "react"; +import { useState, useEffect } from "react"; import { View, Text, StyleSheet, TouchableOpacity } from "react-native"; +import { router } from "expo-router"; import ArrowRight from "@/assets/images/Media/arrowRight.svg"; +import axios from "axios"; +import { useAuthContext } from "../AuthProvider"; type FriendRatingsProps = { - count: number; + media_id: string; + media_type: string; }; -const FriendRatings = ({ count }: FriendRatingsProps) => { +const FriendRatings = ({ media_id, media_type }: FriendRatingsProps) => { + const BASE_URL = process.env.EXPO_PUBLIC_BASE_URL; + const [friendsReviews, setFriendsReviews] = useState([]); + const { userId } = useAuthContext(); + + useEffect(() => { + axios + .get(`${BASE_URL}/reviews/media/${media_id}/${userId}/following`, { + params: { + media_type: media_type, + }, + }) + .then((response) => setFriendsReviews(response.data)) + .catch((error) => console.error(error)); + }, []); + return ( console.log("pressed!")} + onPress={() => + router.push({ + pathname: "/MediaReviewsPage", + params: { + media_id: media_id, + user_id: userId, + media_type: media_type, + filter: "friend", + }, + }) + } > Reviewed by - {count} friends + + {friendsReviews?.length ?? 0}x friends + diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 81ae9b02..46645e65 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1999,22 +1999,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-typeof-symbol": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.25.9.tgz", - "integrity": "sha512-v61XqUMiueJROUv66BVIOi0Fv/CUuZuZMl5NkRoCVxLAnMexZ0A3kMe7vvZ0nulxMuMp0Mk6S5hNh48yki08ZA==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "node_modules/@babel/plugin-transform-typescript": { "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.25.9.tgz", @@ -17245,65 +17229,6 @@ "node": ">=4" } }, - "node_modules/ora/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "license": "MIT", - "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/ora/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/ora/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "license": "MIT", - "dependencies": { - "color-name": "1.1.3" - } - }, - "node_modules/ora/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "license": "MIT" - }, - "node_modules/ora/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "license": "MIT", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/ora/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/ora/node_modules/strip-ansi": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz",